# 2026-05-02 — Admin CRUD (Phase A) > Companion to `Initial.md`. Predecessor: `2026-05-02-customer-account.md`. Closes Phase A of the post-payments UX plan. ## Milestone The practice can now manage all of its data from the browser — no SQL, no `seed.ts` re-runs. Services, rooms, therapists, working hours, overrides, and room blocks all have full CRUD UIs under `/admin`. Together with the bookings list and create-booking page already in place, the admin shell is functionally complete for v1 minus payments. ## What landed | Area | Pages | |---|---| | Services | `/admin/services` (list with active/inactive + counts), `/admin/services/new`, `/admin/services/[id]` | | Rooms | `/admin/rooms` (list + capability tags + booking/block counts), `/admin/rooms/new`, `/admin/rooms/[id]`, `/admin/rooms/[id]/blocks` (list + add + delete) | | Therapists | `/admin/therapists` (list + qualifications + service count), `/admin/therapists/new`, `/admin/therapists/[id]`, `/admin/therapists/[id]/availability` (weekly hours editor + overrides list/add/delete) | Plus: - `src/app/admin/layout.tsx` — added Services / Rooms / Therapists nav links - `src/lib/forms.ts` — shared parsers (`parseInt10`, `parseDollarsToCents`, `centsToDollars`, `parseTags`, `tagsToInputValue`, `parseBool`, `parseStringTrim`) - `src/lib/working-hours.ts` — `WEEKDAYS` constant + `timeToMin`/`minToTime` for `` interop - Per-area `_form.tsx` shared form components (Service, Room, Therapist) so create + edit pages reuse the same UI ## What's verified - `pnpm test` — 71/71 (no new tests; this session is pure UI on already-tested data layer) - `pnpm lint` — clean - `pnpm exec tsc --noEmit` — clean - Live smoke: signed in as admin@touchbase.local, all 11 new admin pages return 200: ``` /admin/services, /admin/services/new, /admin/services/[id] /admin/rooms, /admin/rooms/new, /admin/rooms/[id], /admin/rooms/[id]/blocks /admin/therapists, /admin/therapists/new, /admin/therapists/[id], /admin/therapists/[id]/availability ``` The form server actions weren't replayed via curl (Next.js 16 RPC encoding rejects manual replays — same constraint we've documented). Each action is a thin Prisma `create`/`update`/`deleteMany`+`createMany` against tested models; the seed script already exercises the same query shapes successfully. ## Decisions ratified | Decision | Resolution | |---|---| | Form architecture | Shared `_form.tsx` per entity (private `_` directory ignored by App Router routing). `create` and `edit` pages both render the same form with different action props. Editor pages pass `id` so the form renders a hidden input. | | Tag input | Comma-separated text field. Parsed server-side: lowercased, trimmed, deduped via filter. Reason: zero client JS; users see exactly what they typed. | | Soft delete | All entities use `active` boolean. **No hard-delete** anywhere (would orphan bookings). Admin sees `active=false` rows greyed-out. | | Email uniqueness on therapist create | Pre-checked before `db.user.create`; throws a clear error rather than letting Prisma's generic unique-constraint violation bubble up. | | Therapist edit doesn't change email | Email is the sign-in identity; changing it would silently break Auth.js sessions. Field rendered disabled in edit mode. | | Working hours model | One shift per day per therapist. Split-shift support via `EXTRA_HOURS` overrides. Reason: 90% of practice schedules are single-shift; split-shift edge cases live in the override table where they belong. | | Time inputs | HTML `` (HH:mm). Converted to/from minutes-from-midnight via `src/lib/working-hours.ts`. | | Override datetime inputs | HTML ``. Server parses with `fromZonedTime` (date-fns-tz) using `APP_TZ`. | | Working-hours save | Replace-all transaction: `deleteMany` then `createMany` for the therapist's rows. Reason: simpler than diffing; one txn. | | Tag/service updates on therapist edit | Same replace-all pattern: delete + recreate join rows in a transaction. | ## Gotchas hit ### Nested `