# 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 `
` is invalid HTML First pass had the edit page wrap `ServiceForm` (which is itself a ``) in another `` to add a hidden `id`. Browsers silently break nested forms. Fix: `ServiceForm` accepts an optional `id` prop and renders the hidden input itself. ### Unused `toLocalInputValue` import Started writing the override editor with prefilled datetime-local values but settled on always-empty (the form is for adding new). Removed the helper to avoid the lint warning. ## Open questions 1. Customer-visible brand name (still pending) 2. Currency (USD assumed; admin price inputs are labeled "USD") 3. Stripe account ownership (not blocking until we get to 5d) 4. **NEW**: bulk operations? E.g. "set Tue–Fri 10–19 for all therapists" or "block Room 7 every Sunday." Defer until someone asks for it. 5. **NEW**: audit logging for admin actions. We have an `AuditLog` table but the new CRUD actions don't write to it. Worth wiring up before launch. ## Roadmap status UX-completeness phases (the user's "complete the rest of the UX" pivot before payments): - **A — Admin CRUD: done 2026-05-02 (this session)** - B — Therapist self-serve at `/therapist` — own working hours + time-off - C — Customer reschedule + admin "mark complete/no-show" + booking detail pages - D — PWA shell (manifest + service worker) per Initial.md §11 - E — Polish — 404, empty states, mobile-first review, dark-mode glance Backend roadmap unchanged: 5d Stripe → 5e reminders, after UX is rounded out. ## Recommended next step **Phase B — therapist self-serve**. The smallest remaining UX chunk that's actually load-bearing. Per `Initial.md` decision log, therapists set their own availability; admin is the override path, not the primary path. Implementation: - `/therapist` layout that requires `role=THERAPIST` - `/therapist/availability` — same page as admin's, scoped to the signed-in therapist - `/therapist/bookings` — read-only list of upcoming bookings on this therapist - Reuse the existing form helpers; no new infrastructure ~half-day. After B, Phase C (~1 day) → D (~1 day) → E (~half-day) and we're UX-complete for v1 minus payments. ## How to resume ```bash cd /Users/noise/Documents/code/touchbase docker-compose up -d postgres mailpit pnpm db:seed pnpm dev # Sign in as admin@touchbase.local at /login # Click around: /admin/services, /admin/rooms, /admin/therapists, etc. # Try: create a new service, add a tag, deactivate it, edit a therapist's working hours, add an override, add a room block. ```