6.5 KiB
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 linkssrc/lib/forms.ts— shared parsers (parseInt10,parseDollarsToCents,centsToDollars,parseTags,tagsToInputValue,parseBool,parseStringTrim)src/lib/working-hours.ts—WEEKDAYSconstant +timeToMin/minToTimefor<input type="time">interop- Per-area
_form.tsxshared 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— cleanpnpm 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 <input type="time"> (HH:mm). Converted to/from minutes-from-midnight via src/lib/working-hours.ts. |
| Override datetime inputs | HTML <input type="datetime-local">. 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 <form> is invalid HTML
First pass had the edit page wrap ServiceForm (which is itself a <form>) in another <form> 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
- Customer-visible brand name (still pending)
- Currency (USD assumed; admin price inputs are labeled "USD")
- Stripe account ownership (not blocking until we get to 5d)
- 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.
- NEW: audit logging for admin actions. We have an
AuditLogtable 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:
/therapistlayout that requiresrole=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
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.