initial ux

This commit is contained in:
2026-05-02 09:25:07 -04:00
parent dbca91e06b
commit 3dfc84aa43
18 changed files with 1723 additions and 0 deletions

View File

@@ -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 `<input type="time">` 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 `<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
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 TueFri 1019 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.
```

View File

@@ -46,6 +46,15 @@ export default async function AdminLayout({
<Link href="/admin/bookings" className="hover:text-zinc-900 dark:hover:text-zinc-100">
Bookings
</Link>
<Link href="/admin/services" className="hover:text-zinc-900 dark:hover:text-zinc-100">
Services
</Link>
<Link href="/admin/rooms" className="hover:text-zinc-900 dark:hover:text-zinc-100">
Rooms
</Link>
<Link href="/admin/therapists" className="hover:text-zinc-900 dark:hover:text-zinc-100">
Therapists
</Link>
</nav>
</div>
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">

View File

@@ -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<void> {
"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<void> {
"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 (
<div className="mx-auto max-w-3xl">
<Link
href="/admin/rooms"
className="mb-4 inline-block text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
All rooms
</Link>
<h1 className="mb-1 text-xl font-semibold tracking-tight">{room.name}</h1>
<p className="mb-6 text-sm text-zinc-500">Blocks (maintenance, deep clean, etc.) {TZ}</p>
{room.blocks.length === 0 ? (
<p className="mb-4 rounded-md border border-zinc-200 bg-white p-4 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
No blocks scheduled.
</p>
) : (
<ul className="mb-6 grid gap-2">
{room.blocks.map((b) => (
<li
key={b.id}
className="flex items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div>
{formatLocal(b.startsAt)} {formatLocal(b.endsAt)}
{b.reason && (
<span className="ml-2 text-zinc-500"> {b.reason}</span>
)}
</div>
<form action={deleteBlock}>
<input type="hidden" name="id" value={b.id} />
<input type="hidden" name="roomId" value={id} />
<button
type="submit"
className="rounded-md border border-zinc-300 px-2 py-1 text-xs text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-900"
>
Delete
</button>
</form>
</li>
))}
</ul>
)}
<form
action={addBlock}
className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
<input type="hidden" name="roomId" value={id} />
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Add block
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Starts at (local)
</label>
<input
type="datetime-local"
name="startsAt"
required
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Ends at (local)
</label>
<input
type="datetime-local"
name="endsAt"
required
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Reason (optional)
</label>
<input
name="reason"
placeholder="deep clean, repair, etc."
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
</div>
<div className="mt-3 flex justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
Add block
</button>
</div>
</form>
</div>
);
}

View File

@@ -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<void> {
"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 (
<RoomForm
action={updateRoom}
values={values}
submitLabel="Save changes"
id={id}
/>
);
}

View File

@@ -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<void>;
values: RoomFormValues;
submitLabel: string;
id?: string;
}) {
return (
<div className="mx-auto max-w-xl">
<Link
href="/admin/rooms"
className="mb-4 inline-block text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
All rooms
</Link>
<form
action={action}
className="grid gap-4 rounded-md border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950"
>
{id && <input type="hidden" name="id" value={id} />}
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Name
</label>
<input
name="name"
defaultValue={values.name}
required
placeholder="Room 1 — Sunset"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Capability tags (comma-separated)
</label>
<input
name="tags"
defaultValue={values.tags}
placeholder="hot-stone-equipped, prenatal-table"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-xs dark:border-zinc-700 dark:bg-zinc-900"
/>
<p className="mt-1 text-xs text-zinc-500">
Services with required room tags will only match rooms that have all of them.
</p>
</div>
<div>
<label className="flex items-center gap-2 text-sm">
<input
name="active"
type="checkbox"
defaultChecked={values.active}
className="h-4 w-4 rounded border-zinc-300 dark:border-zinc-700"
/>
Active (available for booking)
</label>
</div>
<div className="flex justify-end gap-2">
<Link
href="/admin/rooms"
className="rounded-md border border-zinc-300 px-4 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Cancel
</Link>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
{submitLabel}
</button>
</div>
</form>
</div>
);
}

View File

@@ -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<void> {
"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 (
<RoomForm action={createRoom} values={EMPTY_ROOM} submitLabel="Create room" />
);
}

View File

@@ -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 (
<div className="mx-auto max-w-5xl">
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-xl font-semibold tracking-tight">Rooms</h1>
<Link
href="/admin/rooms/new"
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
New room
</Link>
</div>
{rooms.length === 0 ? (
<p className="rounded-md border border-zinc-200 bg-white p-6 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
No rooms yet.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Capabilities</th>
<th className="px-4 py-2 font-medium">Bookings</th>
<th className="px-4 py-2 font-medium">Blocks</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{rooms.map((r) => (
<tr key={r.id} className={r.active ? "" : "text-zinc-400"}>
<td className="px-4 py-2">
<Link
href={`/admin/rooms/${r.id}`}
className="font-medium hover:underline"
>
{r.name}
</Link>
</td>
<td className="px-4 py-2 font-mono text-xs">
{r.tags.length === 0 ? (
<span className="text-zinc-400"></span>
) : (
r.tags.map((t) => t.tag).join(", ")
)}
</td>
<td className="px-4 py-2">{r._count.bookings}</td>
<td className="px-4 py-2">{r._count.blocks}</td>
<td className="px-4 py-2">
{r.active ? (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800 dark:bg-green-900/40 dark:text-green-200">
active
</span>
) : (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
inactive
</span>
)}
</td>
<td className="px-4 py-2">
<Link
href={`/admin/rooms/${r.id}/blocks`}
className="text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Manage blocks
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -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<void> {
"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 (
<ServiceForm
action={updateService}
values={values}
submitLabel="Save changes"
id={id}
/>
);
}

View File

@@ -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<void>;
values: ServiceFormValues;
submitLabel: string;
error?: string;
id?: string;
}) {
return (
<div className="mx-auto max-w-2xl">
<Link
href="/admin/services"
className="mb-4 inline-block text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
All services
</Link>
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200">
{error}
</div>
)}
<form
action={action}
className="grid gap-4 rounded-md border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950"
>
{id && <input type="hidden" name="id" value={id} />}
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Name
</label>
<input
name="name"
defaultValue={values.name}
required
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Description
</label>
<textarea
name="description"
defaultValue={values.description}
rows={2}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Duration (min)
</label>
<input
name="durationMin"
type="number"
min="1"
defaultValue={values.durationMin}
required
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Buffer after (min)
</label>
<input
name="bufferAfterMin"
type="number"
min="0"
defaultValue={values.bufferAfterMin}
required
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Price (USD)
</label>
<input
name="priceDollars"
type="number"
min="0"
step="0.01"
defaultValue={values.priceDollars}
required
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Deposit (USD)
</label>
<input
name="depositDollars"
type="number"
min="0"
step="0.01"
defaultValue={values.depositDollars}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Required therapist tags (comma-separated)
</label>
<input
name="requiredTherapistTags"
defaultValue={values.requiredTherapistTags}
placeholder="swedish, deep-tissue"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-xs dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Required room tags (comma-separated)
</label>
<input
name="requiredRoomTags"
defaultValue={values.requiredRoomTags}
placeholder="hot-stone-equipped"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-xs dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm">
<input
name="active"
type="checkbox"
defaultChecked={values.active}
className="h-4 w-4 rounded border-zinc-300 dark:border-zinc-700"
/>
Active (offered for booking)
</label>
</div>
<div className="flex justify-end gap-2">
<Link
href="/admin/services"
className="rounded-md border border-zinc-300 px-4 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Cancel
</Link>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
{submitLabel}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import {
parseBool,
parseDollarsToCents,
parseInt10,
parseStringTrim,
parseTags,
} from "@/lib/forms";
import { ServiceForm, EMPTY_SERVICE } from "../_form";
export const metadata = { title: "New service — TouchBase" };
export const dynamic = "force-dynamic";
async function createService(formData: FormData): Promise<void> {
"use server";
const name = parseStringTrim(formData.get("name"));
if (!name) return;
const created = await db.service.create({
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/${created.id}`);
}
export default function NewServicePage() {
return (
<ServiceForm
action={createService}
values={EMPTY_SERVICE}
submitLabel="Create service"
/>
);
}

View File

@@ -0,0 +1,86 @@
import Link from "next/link";
import { db } from "@/lib/db";
export const metadata = { title: "Services — TouchBase" };
export const dynamic = "force-dynamic";
export default async function ServicesPage() {
const services = await db.service.findMany({
orderBy: [{ active: "desc" }, { name: "asc" }],
include: { _count: { select: { therapists: true, bookings: true } } },
});
return (
<div className="mx-auto max-w-5xl">
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-xl font-semibold tracking-tight">Services</h1>
<Link
href="/admin/services/new"
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
New service
</Link>
</div>
{services.length === 0 ? (
<p className="rounded-md border border-zinc-200 bg-white p-6 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
No services yet.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Duration</th>
<th className="px-4 py-2 font-medium">Price</th>
<th className="px-4 py-2 font-medium">Therapists</th>
<th className="px-4 py-2 font-medium">Bookings</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{services.map((s) => (
<tr key={s.id} className={s.active ? "" : "text-zinc-400"}>
<td className="px-4 py-2">
<Link
href={`/admin/services/${s.id}`}
className="font-medium hover:underline"
>
{s.name}
</Link>
</td>
<td className="px-4 py-2 font-mono text-xs">
{s.durationMin}m + {s.bufferAfterMin}
</td>
<td className="px-4 py-2 font-mono text-xs">
${(s.priceCents / 100).toFixed(0)}
{s.depositCents > 0 && (
<span className="text-zinc-500">
{" "}
/ ${(s.depositCents / 100).toFixed(0)} dep
</span>
)}
</td>
<td className="px-4 py-2">{s._count.therapists}</td>
<td className="px-4 py-2">{s._count.bookings}</td>
<td className="px-4 py-2">
{s.active ? (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800 dark:bg-green-900/40 dark:text-green-200">
active
</span>
) : (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
inactive
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,286 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { fromZonedTime } from "date-fns-tz";
import { db } from "@/lib/db";
import { parseBool, parseStringTrim } from "@/lib/forms";
import { minToTime, timeToMin, WEEKDAYS } from "@/lib/working-hours";
export const metadata = { title: "Therapist availability — 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 saveWorkingHours(formData: FormData): Promise<void> {
"use server";
const therapistId = parseStringTrim(formData.get("therapistId"));
if (!therapistId) return;
// For each weekday, read open/start/end. Replace all existing rows in one tx.
const newRows: { weekday: number; startMin: number; endMin: number }[] = [];
for (const { weekday } of WEEKDAYS) {
const open = parseBool(formData.get(`open_${weekday}`));
if (!open) continue;
const start = timeToMin(parseStringTrim(formData.get(`start_${weekday}`)));
const end = timeToMin(parseStringTrim(formData.get(`end_${weekday}`)));
if (start === null || end === null || end <= start) continue;
newRows.push({ weekday, startMin: start, endMin: end });
}
await db.$transaction([
db.workingHours.deleteMany({ where: { therapistId } }),
db.workingHours.createMany({
data: newRows.map((r) => ({ ...r, therapistId })),
}),
]);
redirect(`/admin/therapists/${therapistId}/availability`);
}
async function addOverride(formData: FormData): Promise<void> {
"use server";
const therapistId = parseStringTrim(formData.get("therapistId"));
if (!therapistId) return;
const kindRaw = parseStringTrim(formData.get("kind"));
const kind = kindRaw === "EXTRA_HOURS" ? "EXTRA_HOURS" : "BLOCK";
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.availabilityOverride.create({
data: { therapistId, kind, startsAt, endsAt, reason },
});
redirect(`/admin/therapists/${therapistId}/availability`);
}
async function deleteOverride(formData: FormData): Promise<void> {
"use server";
const id = parseStringTrim(formData.get("id"));
const therapistId = parseStringTrim(formData.get("therapistId"));
if (!id || !therapistId) return;
await db.availabilityOverride.delete({ where: { id } });
redirect(`/admin/therapists/${therapistId}/availability`);
}
export default async function TherapistAvailabilityPage({
params,
}: {
params: Params;
}) {
const { id } = await params;
const therapist = await db.therapist.findUnique({
where: { userId: id },
include: {
user: { select: { name: true, email: true } },
workingHours: true,
overrides: { orderBy: { startsAt: "asc" } },
},
});
if (!therapist) notFound();
// Map weekday → existing WorkingHours row (single shift per day in v1)
const whByDay = new Map<number, { startMin: number; endMin: number }>();
for (const w of therapist.workingHours) {
whByDay.set(w.weekday, { startMin: w.startMin, endMin: w.endMin });
}
return (
<div className="mx-auto max-w-3xl">
<Link
href="/admin/therapists"
className="mb-4 inline-block text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
All therapists
</Link>
<h1 className="mb-1 text-xl font-semibold tracking-tight">
{therapist.user.name ?? therapist.user.email}
</h1>
<p className="mb-6 text-sm text-zinc-500">Availability {TZ}</p>
{/* Weekly working hours editor */}
<section className="mb-8">
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Weekly working hours
</h2>
<form
action={saveWorkingHours}
className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
<input type="hidden" name="therapistId" value={id} />
<div className="grid gap-2">
{WEEKDAYS.map(({ weekday, name }) => {
const existing = whByDay.get(weekday);
return (
<div
key={weekday}
className="grid grid-cols-12 items-center gap-2 text-sm"
>
<label className="col-span-4 flex items-center gap-2">
<input
type="checkbox"
name={`open_${weekday}`}
defaultChecked={existing !== undefined}
className="h-4 w-4 rounded border-zinc-300 dark:border-zinc-700"
/>
{name}
</label>
<input
type="time"
name={`start_${weekday}`}
defaultValue={
existing ? minToTime(existing.startMin) : "10:00"
}
className="col-span-3 rounded-md border border-zinc-300 bg-white px-2 py-1 dark:border-zinc-700 dark:bg-zinc-900"
/>
<span className="col-span-1 text-center text-zinc-400">to</span>
<input
type="time"
name={`end_${weekday}`}
defaultValue={
existing ? minToTime(existing.endMin) : "19:00"
}
className="col-span-3 rounded-md border border-zinc-300 bg-white px-2 py-1 dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
);
})}
</div>
<p className="mt-3 text-xs text-zinc-500">
One shift per day. For split shifts, add an EXTRA_HOURS override below.
</p>
<div className="mt-3 flex justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
Save working hours
</button>
</div>
</form>
</section>
{/* Overrides */}
<section>
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Overrides (PTO, extra hours)
</h2>
{therapist.overrides.length > 0 && (
<ul className="mb-4 grid gap-2">
{therapist.overrides.map((o) => (
<li
key={o.id}
className="flex items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div>
<span
className={
o.kind === "BLOCK"
? "mr-2 rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-800 dark:bg-red-900/40 dark:text-red-200"
: "mr-2 rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
}
>
{o.kind === "BLOCK" ? "BLOCK" : "EXTRA"}
</span>
{formatLocal(o.startsAt)} {formatLocal(o.endsAt)}
{o.reason && (
<span className="ml-2 text-zinc-500"> {o.reason}</span>
)}
</div>
<form action={deleteOverride}>
<input type="hidden" name="id" value={o.id} />
<input type="hidden" name="therapistId" value={id} />
<button
type="submit"
className="rounded-md border border-zinc-300 px-2 py-1 text-xs text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-900"
>
Delete
</button>
</form>
</li>
))}
</ul>
)}
<form
action={addOverride}
className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
<input type="hidden" name="therapistId" value={id} />
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Kind
</label>
<select
name="kind"
defaultValue="BLOCK"
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
>
<option value="BLOCK">Block (PTO, sick, busy)</option>
<option value="EXTRA_HOURS">Extra hours (work outside normal schedule)</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Reason (optional)
</label>
<input
name="reason"
placeholder="vacation, doctor visit, etc."
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Starts at (local)
</label>
<input
type="datetime-local"
name="startsAt"
required
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Ends at (local)
</label>
<input
type="datetime-local"
name="endsAt"
required
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
</div>
<div className="mt-3 flex justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
Add override
</button>
</div>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { notFound, redirect } from "next/navigation";
import { db } from "@/lib/db";
import {
parseBool,
parseStringTrim,
parseTags,
tagsToInputValue,
} from "@/lib/forms";
import { TherapistForm, type TherapistFormValues } from "../_form";
export const metadata = { title: "Edit therapist — TouchBase" };
export const dynamic = "force-dynamic";
type Params = Promise<{ id: string }>;
async function updateTherapist(formData: FormData): Promise<void> {
"use server";
const id = parseStringTrim(formData.get("id"));
if (!id) return;
const name = parseStringTrim(formData.get("name")) || null;
const bio = parseStringTrim(formData.get("bio")) || null;
const tags = parseTags(formData.get("tags"));
const serviceIds = formData
.getAll("serviceIds")
.map((v) => String(v))
.filter(Boolean);
const active = parseBool(formData.get("active"));
await db.$transaction([
db.user.update({ where: { id }, data: { name } }),
db.therapist.update({
where: { userId: id },
data: { bio, active },
}),
db.therapistTag.deleteMany({ where: { therapistId: id } }),
db.therapistTag.createMany({
data: tags.map((tag) => ({ therapistId: id, tag })),
}),
db.serviceTherapist.deleteMany({ where: { therapistId: id } }),
db.serviceTherapist.createMany({
data: serviceIds.map((serviceId) => ({ therapistId: id, serviceId })),
}),
]);
redirect("/admin/therapists");
}
export default async function EditTherapistPage({
params,
}: {
params: Params;
}) {
const { id } = await params;
const [therapist, services] = await Promise.all([
db.therapist.findUnique({
where: { userId: id },
include: {
user: { select: { email: true, name: true } },
tags: { orderBy: { tag: "asc" } },
services: { select: { serviceId: true } },
},
}),
db.service.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
if (!therapist) notFound();
const values: TherapistFormValues = {
email: therapist.user.email,
name: therapist.user.name ?? "",
bio: therapist.bio ?? "",
tags: tagsToInputValue(therapist.tags.map((t) => t.tag)),
serviceIds: therapist.services.map((s) => s.serviceId),
active: therapist.active,
};
return (
<TherapistForm
action={updateTherapist}
values={values}
services={services}
submitLabel="Save changes"
id={id}
/>
);
}

View File

@@ -0,0 +1,178 @@
import Link from "next/link";
export type TherapistFormValues = {
email: string;
name: string;
bio: string;
tags: string; // comma-separated
serviceIds: string[]; // ids of services this therapist performs
active: boolean;
};
export const EMPTY_THERAPIST: TherapistFormValues = {
email: "",
name: "",
bio: "",
tags: "",
serviceIds: [],
active: true,
};
export type ServiceOption = { id: string; name: string };
export function TherapistForm({
action,
values,
services,
submitLabel,
error,
id,
}: {
action: (formData: FormData) => void | Promise<void>;
values: TherapistFormValues;
services: ServiceOption[];
submitLabel: string;
error?: string;
id?: string;
}) {
const checked = new Set(values.serviceIds);
return (
<div className="mx-auto max-w-2xl">
<Link
href="/admin/therapists"
className="mb-4 inline-block text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
All therapists
</Link>
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200">
{error}
</div>
)}
<form
action={action}
className="grid gap-4 rounded-md border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950"
>
{id && <input type="hidden" name="id" value={id} />}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Email
</label>
<input
name="email"
type="email"
required
defaultValue={values.email}
disabled={!!id}
placeholder="mei@touchbase.local"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm disabled:bg-zinc-100 disabled:text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:disabled:bg-zinc-800"
/>
{id && (
<p className="mt-1 text-xs text-zinc-500">
Email cannot be changed (it&apos;s the sign-in identity).
</p>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Name
</label>
<input
name="name"
defaultValue={values.name}
placeholder="Mei Tanaka"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Bio
</label>
<textarea
name="bio"
defaultValue={values.bio}
rows={3}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Qualification tags (comma-separated)
</label>
<input
name="tags"
defaultValue={values.tags}
placeholder="swedish, deep-tissue, prenatal-cert"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-xs dark:border-zinc-700 dark:bg-zinc-900"
/>
<p className="mt-1 text-xs text-zinc-500">
Therapist will only be offered for services whose required tags are all present here.
</p>
</div>
<div>
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-zinc-500">
Services performed
</div>
{services.length === 0 ? (
<p className="text-xs text-zinc-500">
No services configured yet create services first.
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{services.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="serviceIds"
value={s.id}
defaultChecked={checked.has(s.id)}
className="h-4 w-4 rounded border-zinc-300 dark:border-zinc-700"
/>
{s.name}
</label>
))}
</div>
)}
<p className="mt-1 text-xs text-zinc-500">
Tag matching is the necessary condition; this checkbox is the additional opt-in.
</p>
</div>
<div>
<label className="flex items-center gap-2 text-sm">
<input
name="active"
type="checkbox"
defaultChecked={values.active}
className="h-4 w-4 rounded border-zinc-300 dark:border-zinc-700"
/>
Active (offered for booking)
</label>
</div>
<div className="flex justify-end gap-2">
<Link
href="/admin/therapists"
className="rounded-md border border-zinc-300 px-4 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Cancel
</Link>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
{submitLabel}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { parseBool, parseStringTrim, parseTags } from "@/lib/forms";
import { TherapistForm, EMPTY_THERAPIST } from "../_form";
export const metadata = { title: "New therapist — TouchBase" };
export const dynamic = "force-dynamic";
async function createTherapist(formData: FormData): Promise<void> {
"use server";
const email = parseStringTrim(formData.get("email")).toLowerCase();
if (!email) return;
const name = parseStringTrim(formData.get("name")) || null;
const bio = parseStringTrim(formData.get("bio")) || null;
const tags = parseTags(formData.get("tags"));
const serviceIds = formData
.getAll("serviceIds")
.map((v) => String(v))
.filter(Boolean);
const active = parseBool(formData.get("active"));
// Reject duplicate emails up front
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
throw new Error(`A user already exists with email ${email}`);
}
const created = await db.user.create({
data: {
email,
name,
role: "THERAPIST",
therapist: {
create: {
bio,
active,
tags: { create: tags.map((tag) => ({ tag })) },
services: {
create: serviceIds.map((serviceId) => ({ serviceId })),
},
},
},
},
});
redirect(`/admin/therapists/${created.id}`);
}
export default async function NewTherapistPage() {
const services = await db.service.findMany({
where: { active: true },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return (
<TherapistForm
action={createTherapist}
values={EMPTY_THERAPIST}
services={services}
submitLabel="Create therapist"
/>
);
}

View File

@@ -0,0 +1,95 @@
import Link from "next/link";
import { db } from "@/lib/db";
export const metadata = { title: "Therapists — TouchBase" };
export const dynamic = "force-dynamic";
export default async function TherapistsPage() {
const therapists = await db.therapist.findMany({
include: {
user: { select: { name: true, email: true } },
tags: { select: { tag: true }, orderBy: { tag: "asc" } },
_count: { select: { services: true, bookings: true } },
},
orderBy: [{ active: "desc" }, { user: { name: "asc" } }],
});
return (
<div className="mx-auto max-w-5xl">
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-xl font-semibold tracking-tight">Therapists</h1>
<Link
href="/admin/therapists/new"
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
New therapist
</Link>
</div>
{therapists.length === 0 ? (
<p className="rounded-md border border-zinc-200 bg-white p-6 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
No therapists yet.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Email</th>
<th className="px-4 py-2 font-medium">Qualifications</th>
<th className="px-4 py-2 font-medium">Services</th>
<th className="px-4 py-2 font-medium">Bookings</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{therapists.map((t) => (
<tr key={t.userId} className={t.active ? "" : "text-zinc-400"}>
<td className="px-4 py-2">
<Link
href={`/admin/therapists/${t.userId}`}
className="font-medium hover:underline"
>
{t.user.name ?? <span className="italic text-zinc-500">unnamed</span>}
</Link>
</td>
<td className="px-4 py-2 font-mono text-xs">{t.user.email}</td>
<td className="px-4 py-2 font-mono text-xs">
{t.tags.length === 0 ? (
<span className="text-zinc-400"></span>
) : (
t.tags.map((tt) => tt.tag).join(", ")
)}
</td>
<td className="px-4 py-2">{t._count.services}</td>
<td className="px-4 py-2">{t._count.bookings}</td>
<td className="px-4 py-2">
{t.active ? (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800 dark:bg-green-900/40 dark:text-green-200">
active
</span>
) : (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
inactive
</span>
)}
</td>
<td className="px-4 py-2">
<Link
href={`/admin/therapists/${t.userId}/availability`}
className="text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Availability
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

40
src/lib/forms.ts Normal file
View File

@@ -0,0 +1,40 @@
// Tiny helpers for server-action form parsing.
export function parseInt10(v: FormDataEntryValue | null, fallback = 0): number {
if (typeof v !== "string") return fallback;
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : fallback;
}
export function parseDollarsToCents(v: FormDataEntryValue | null): number {
if (typeof v !== "string") return 0;
const trimmed = v.trim();
if (trimmed === "") return 0;
const f = Number.parseFloat(trimmed);
if (!Number.isFinite(f)) return 0;
return Math.round(f * 100);
}
export function centsToDollars(cents: number): string {
return (cents / 100).toFixed(2).replace(/\.00$/, "");
}
export function parseTags(v: FormDataEntryValue | null): string[] {
if (typeof v !== "string") return [];
return v
.split(",")
.map((t) => t.trim().toLowerCase())
.filter((t) => t.length > 0);
}
export function tagsToInputValue(tags: string[]): string {
return tags.join(", ");
}
export function parseBool(v: FormDataEntryValue | null): boolean {
return v === "on" || v === "true" || v === "1";
}
export function parseStringTrim(v: FormDataEntryValue | null): string {
return typeof v === "string" ? v.trim() : "";
}

32
src/lib/working-hours.ts Normal file
View File

@@ -0,0 +1,32 @@
// Helpers for converting between HTML time inputs and minutes-from-midnight.
const WEEKDAY_NAMES = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
export const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6].map((i) => ({
weekday: i,
name: WEEKDAY_NAMES[i],
}));
/** "HH:mm" → minutes from midnight (or null on bad input). */
export function timeToMin(v: string | null | undefined): number | null {
if (!v || typeof v !== "string") return null;
const [h, m] = v.split(":").map((s) => Number.parseInt(s, 10));
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
if (h < 0 || h > 24 || m < 0 || m > 59) return null;
return h * 60 + m;
}
/** minutes from midnight → "HH:mm" (zero-padded). */
export function minToTime(min: number): string {
const h = Math.floor(min / 60).toString().padStart(2, "0");
const m = (min % 60).toString().padStart(2, "0");
return `${h}:${m}`;
}