diff --git a/docs/progress/2026-05-02-admin-crud.md b/docs/progress/2026-05-02-admin-crud.md new file mode 100644 index 0000000..86f70ef --- /dev/null +++ b/docs/progress/2026-05-02-admin-crud.md @@ -0,0 +1,102 @@ +# 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. +``` diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index d4b3b4f..7beb8f0 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -46,6 +46,15 @@ export default async function AdminLayout({ Bookings + + Services + + + Rooms + + + Therapists +
diff --git a/src/app/admin/rooms/[id]/blocks/page.tsx b/src/app/admin/rooms/[id]/blocks/page.tsx new file mode 100644 index 0000000..1a7953c --- /dev/null +++ b/src/app/admin/rooms/[id]/blocks/page.tsx @@ -0,0 +1,158 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { fromZonedTime } from "date-fns-tz"; +import { db } from "@/lib/db"; +import { parseStringTrim } from "@/lib/forms"; + +export const metadata = { title: "Room blocks — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +type Params = Promise<{ id: string }>; + +function formatLocal(d: Date): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: TZ, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(d); +} + +async function addBlock(formData: FormData): Promise { + "use server"; + const roomId = parseStringTrim(formData.get("roomId")); + if (!roomId) return; + const startsLocal = parseStringTrim(formData.get("startsAt")); + const endsLocal = parseStringTrim(formData.get("endsAt")); + const reason = parseStringTrim(formData.get("reason")) || null; + if (!startsLocal || !endsLocal) return; + + const startsAt = fromZonedTime(startsLocal, TZ); + const endsAt = fromZonedTime(endsLocal, TZ); + if (endsAt <= startsAt) return; + + await db.roomBlock.create({ + data: { roomId, startsAt, endsAt, reason }, + }); + redirect(`/admin/rooms/${roomId}/blocks`); +} + +async function deleteBlock(formData: FormData): Promise { + "use server"; + const id = parseStringTrim(formData.get("id")); + const roomId = parseStringTrim(formData.get("roomId")); + if (!id || !roomId) return; + await db.roomBlock.delete({ where: { id } }); + redirect(`/admin/rooms/${roomId}/blocks`); +} + +export default async function RoomBlocksPage({ params }: { params: Params }) { + const { id } = await params; + const room = await db.room.findUnique({ + where: { id }, + include: { blocks: { orderBy: { startsAt: "asc" } } }, + }); + if (!room) notFound(); + + return ( +
+ + ← All rooms + + +

{room.name}

+

Blocks (maintenance, deep clean, etc.) — {TZ}

+ + {room.blocks.length === 0 ? ( +

+ No blocks scheduled. +

+ ) : ( +
    + {room.blocks.map((b) => ( +
  • +
    + {formatLocal(b.startsAt)} – {formatLocal(b.endsAt)} + {b.reason && ( + — {b.reason} + )} +
    + + + + +
  • + + ))} +
+ )} + +
+ +

+ Add block +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ ); +} diff --git a/src/app/admin/rooms/[id]/page.tsx b/src/app/admin/rooms/[id]/page.tsx new file mode 100644 index 0000000..b6e7fd0 --- /dev/null +++ b/src/app/admin/rooms/[id]/page.tsx @@ -0,0 +1,57 @@ +import { notFound, redirect } from "next/navigation"; +import { db } from "@/lib/db"; +import { + parseBool, + parseStringTrim, + parseTags, + tagsToInputValue, +} from "@/lib/forms"; +import { RoomForm, type RoomFormValues } from "../_form"; + +export const metadata = { title: "Edit room — TouchBase" }; +export const dynamic = "force-dynamic"; + +type Params = Promise<{ id: string }>; + +async function updateRoom(formData: FormData): Promise { + "use server"; + const id = parseStringTrim(formData.get("id")); + const name = parseStringTrim(formData.get("name")); + if (!id || !name) return; + const tags = parseTags(formData.get("tags")); + await db.$transaction([ + db.room.update({ + where: { id }, + data: { name, active: parseBool(formData.get("active")) }, + }), + db.roomTag.deleteMany({ where: { roomId: id } }), + db.roomTag.createMany({ + data: tags.map((tag) => ({ roomId: id, tag })), + }), + ]); + redirect("/admin/rooms"); +} + +export default async function EditRoomPage({ params }: { params: Params }) { + const { id } = await params; + const room = await db.room.findUnique({ + where: { id }, + include: { tags: { orderBy: { tag: "asc" } } }, + }); + if (!room) notFound(); + + const values: RoomFormValues = { + name: room.name, + tags: tagsToInputValue(room.tags.map((t) => t.tag)), + active: room.active, + }; + + return ( + + ); +} diff --git a/src/app/admin/rooms/_form.tsx b/src/app/admin/rooms/_form.tsx new file mode 100644 index 0000000..ed23b24 --- /dev/null +++ b/src/app/admin/rooms/_form.tsx @@ -0,0 +1,98 @@ +import Link from "next/link"; + +export type RoomFormValues = { + name: string; + tags: string; // comma-separated + active: boolean; +}; + +export const EMPTY_ROOM: RoomFormValues = { + name: "", + tags: "", + active: true, +}; + +export function RoomForm({ + action, + values, + submitLabel, + id, +}: { + action: (formData: FormData) => void | Promise; + values: RoomFormValues; + submitLabel: string; + id?: string; +}) { + return ( +
+ + ← All rooms + + +
+ {id && } + +
+ + +
+ +
+ + +

+ Services with required room tags will only match rooms that have all of them. +

+
+ +
+ +
+ +
+ + Cancel + + +
+
+
+ ); +} diff --git a/src/app/admin/rooms/new/page.tsx b/src/app/admin/rooms/new/page.tsx new file mode 100644 index 0000000..2aa3961 --- /dev/null +++ b/src/app/admin/rooms/new/page.tsx @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import { db } from "@/lib/db"; +import { parseBool, parseStringTrim, parseTags } from "@/lib/forms"; +import { RoomForm, EMPTY_ROOM } from "../_form"; + +export const metadata = { title: "New room — TouchBase" }; +export const dynamic = "force-dynamic"; + +async function createRoom(formData: FormData): Promise { + "use server"; + const name = parseStringTrim(formData.get("name")); + if (!name) return; + const tags = parseTags(formData.get("tags")); + const created = await db.room.create({ + data: { + name, + active: parseBool(formData.get("active")), + tags: { create: tags.map((tag) => ({ tag })) }, + }, + }); + redirect(`/admin/rooms/${created.id}`); +} + +export default function NewRoomPage() { + return ( + + ); +} diff --git a/src/app/admin/rooms/page.tsx b/src/app/admin/rooms/page.tsx new file mode 100644 index 0000000..9a74930 --- /dev/null +++ b/src/app/admin/rooms/page.tsx @@ -0,0 +1,92 @@ +import Link from "next/link"; +import { db } from "@/lib/db"; + +export const metadata = { title: "Rooms — TouchBase" }; +export const dynamic = "force-dynamic"; + +export default async function RoomsPage() { + const rooms = await db.room.findMany({ + orderBy: [{ active: "desc" }, { name: "asc" }], + include: { + tags: { select: { tag: true }, orderBy: { tag: "asc" } }, + _count: { select: { bookings: true, blocks: true } }, + }, + }); + + return ( +
+
+

Rooms

+ + New room + +
+ + {rooms.length === 0 ? ( +

+ No rooms yet. +

+ ) : ( +
+ + + + + + + + + + + + + {rooms.map((r) => ( + + + + + + + + + ))} + +
NameCapabilitiesBookingsBlocksStatus
+ + {r.name} + + + {r.tags.length === 0 ? ( + + ) : ( + r.tags.map((t) => t.tag).join(", ") + )} + {r._count.bookings}{r._count.blocks} + {r.active ? ( + + active + + ) : ( + + inactive + + )} + + + Manage blocks + +
+
+ )} +
+ ); +} diff --git a/src/app/admin/services/[id]/page.tsx b/src/app/admin/services/[id]/page.tsx new file mode 100644 index 0000000..5324f87 --- /dev/null +++ b/src/app/admin/services/[id]/page.tsx @@ -0,0 +1,70 @@ +import { notFound, redirect } from "next/navigation"; +import { db } from "@/lib/db"; +import { + centsToDollars, + parseBool, + parseDollarsToCents, + parseInt10, + parseStringTrim, + parseTags, + tagsToInputValue, +} from "@/lib/forms"; +import { ServiceForm, type ServiceFormValues } from "../_form"; + +export const metadata = { title: "Edit service — TouchBase" }; +export const dynamic = "force-dynamic"; + +type Params = Promise<{ id: string }>; + +async function updateService(formData: FormData): Promise { + "use server"; + const id = parseStringTrim(formData.get("id")); + const name = parseStringTrim(formData.get("name")); + if (!id || !name) return; + await db.service.update({ + where: { id }, + data: { + name, + description: parseStringTrim(formData.get("description")) || null, + durationMin: parseInt10(formData.get("durationMin"), 60), + bufferAfterMin: parseInt10(formData.get("bufferAfterMin"), 15), + priceCents: parseDollarsToCents(formData.get("priceDollars")), + depositCents: parseDollarsToCents(formData.get("depositDollars")), + requiredTherapistTags: parseTags(formData.get("requiredTherapistTags")), + requiredRoomTags: parseTags(formData.get("requiredRoomTags")), + active: parseBool(formData.get("active")), + }, + }); + redirect("/admin/services"); +} + +export default async function EditServicePage({ + params, +}: { + params: Params; +}) { + const { id } = await params; + const service = await db.service.findUnique({ where: { id } }); + if (!service) notFound(); + + const values: ServiceFormValues = { + name: service.name, + description: service.description ?? "", + durationMin: service.durationMin, + bufferAfterMin: service.bufferAfterMin, + priceDollars: centsToDollars(service.priceCents), + depositDollars: centsToDollars(service.depositCents), + requiredTherapistTags: tagsToInputValue(service.requiredTherapistTags), + requiredRoomTags: tagsToInputValue(service.requiredRoomTags), + active: service.active, + }; + + return ( + + ); +} diff --git a/src/app/admin/services/_form.tsx b/src/app/admin/services/_form.tsx new file mode 100644 index 0000000..d8ae61b --- /dev/null +++ b/src/app/admin/services/_form.tsx @@ -0,0 +1,200 @@ +// Shared form for service create + edit. Used by /new and /[id] pages. + +import Link from "next/link"; + +type ServiceFormValues = { + name: string; + description: string; + durationMin: number; + bufferAfterMin: number; + priceDollars: string; // string for the input value + depositDollars: string; + requiredTherapistTags: string; // comma-separated + requiredRoomTags: string; + active: boolean; +}; + +export const EMPTY_SERVICE: ServiceFormValues = { + name: "", + description: "", + durationMin: 60, + bufferAfterMin: 15, + priceDollars: "0", + depositDollars: "0", + requiredTherapistTags: "", + requiredRoomTags: "", + active: true, +}; + +export type { ServiceFormValues }; + +export function ServiceForm({ + action, + values, + submitLabel, + error, + id, +}: { + action: (formData: FormData) => void | Promise; + values: ServiceFormValues; + submitLabel: string; + error?: string; + id?: string; +}) { + return ( +
+ + ← All services + + + {error && ( +
+ {error} +
+ )} + +
+ {id && } +
+ + +
+ +
+ +