phase B therapist self-serve: /therapist (schedule + availability)

This commit is contained in:
2026-05-02 09:35:31 -04:00
parent 3dfc84aa43
commit a270d83c1a
7 changed files with 687 additions and 186 deletions

View File

@@ -0,0 +1,86 @@
# 2026-05-02 — Therapist Self-Serve (Phase B)
> Companion to `Initial.md`. Predecessor: `2026-05-02-admin-crud.md`. Closes Phase B of the post-payments UX plan.
## Milestone
Therapists can now sign in and manage their own availability without involving admin. They also see their own schedule (upcoming + recent appointments) with customer contact info. The admin-side per-therapist availability editor and the new therapist self-serve editor share the same component, so any future improvement to the editor benefits both surfaces.
## What landed
| Path | Role |
|---|---|
| `src/components/AvailabilityEditor.tsx` | Reusable server component (no client state) — weekly working-hours grid + overrides list/add/delete. Caller supplies the data + 3 server-action callbacks + optional hidden form fields. |
| `src/app/admin/therapists/[id]/availability/page.tsx` (refactored) | Now uses `AvailabilityEditor` with `hiddenFields={{ therapistId: id }}`. Same behavior, less code. |
| `src/app/therapist/layout.tsx` | Auth-required, role-gated to THERAPIST. Friendly redirect/explanation for ADMIN or CUSTOMER hitting the route. Header with "My schedule" + "Availability" + sign-out. |
| `src/app/therapist/page.tsx` | Redirects to `/therapist/bookings` (most-useful default). |
| `src/app/therapist/availability/page.tsx` | Same editor as the admin page, scoped to `session.user.id`. Server actions read therapistId from the session and **ignore any form-supplied therapistId** — defense in depth. Delete-override action also re-checks ownership. |
| `src/app/therapist/bookings/page.tsx` | Read-only schedule. Upcoming table (When, Service+duration, Customer name+email+phone, Room) + recent list with status pills. |
## What's verified
- `pnpm test` — 71/71 (no new tests)
- `pnpm lint` — clean
- `pnpm exec tsc --noEmit` — clean
- Live smoke test as `mei@touchbase.local` (THERAPIST role from seed):
- Magic-link sign-in with `callbackUrl=/therapist` → lands on `/therapist/bookings`
- `/therapist` (no trailing path) → 307 → `/therapist/bookings`
- `/therapist/bookings` shows the two seeded-CLI bookings with customer name + email
- `/therapist/availability` renders working-hours editor (Tuesday-Saturday from seed) + override form
- `/admin/bookings` (as therapist) renders "Not authorized" — correctly gated
## Decisions ratified
| Decision | Resolution |
|---|---|
| Editor reuse | Extracted to `src/components/AvailabilityEditor.tsx`. Caller passes data + actions + optional hidden fields. Reason: avoid 280 LOC of duplication; one place to fix bugs. |
| Therapist actions ignore form-supplied therapistId | Server actions read therapist id from `auth()` session, never from FormData. Reason: a malicious THERAPIST could otherwise edit another therapist's schedule by tampering with the form. The component still has a `hiddenFields` prop for the admin route, but the therapist route doesn't pass any. |
| Delete-override ownership check | Action looks up the override and aborts if `therapistId !== session.user.id`. Reason: defense in depth; the page only shows own overrides but the action could be replayed with someone else's id. |
| Therapist landing | `/therapist` redirects to `/therapist/bookings` (the schedule), not `/therapist/availability`. Reason: most therapists open the app to see "what's next today" — the schedule is the more useful default. |
| Therapist sees customer contact info | Name + email + phone exposed in the schedule table. Reason: legitimate operational need (running late, customer no-show, etc.). Not HIPAA in our scope, but worth being mindful of. |
| Cross-role friendly errors | ADMIN visiting `/therapist` sees "Therapists only" with link to `/admin/therapists`. CUSTOMER sees link to `/account/bookings`. Reason: mistaken navigation should explain itself. |
## Gotchas hit
None this session — clean refactor + extension.
## Open questions
1. Customer-visible brand name (still pending)
2. Currency
3. Stripe account ownership
4. **NEW**: should the therapist schedule include a "mark complete / no-show" button, or do we keep that admin-only? Coming up in Phase C.
5. **NEW**: today's-day highlight in the therapist schedule? Defer until requested.
## Roadmap status
UX-completeness:
- A — Admin CRUD: done 2026-05-02
- **B — Therapist self-serve: done 2026-05-02 (this session)**
- C — Customer reschedule + admin "mark complete/no-show" + booking detail pages
- D — PWA shell
- E — Polish
## Recommended next step
**Phase C — booking lifecycle UX**:
1. **Booking detail page** at `/admin/bookings/[id]` (and maybe `/account/bookings/[id]` for customers): all fields, history, actions.
2. **Reschedule action** for customers (and admin): cancel + rebook in one transaction. Likely add `rescheduleBooking()` to `src/lib/booking.ts` that wraps the operation atomically.
3. **Admin "mark complete / no-show"** buttons on the admin booking detail (or directly on the row in `/admin/bookings`). Adds two more state transitions to handle.
Estimate ~1 day. Mostly composition over existing primitives.
## How to resume
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
pnpm dev
# Sign in as a therapist (e.g. mei@touchbase.local) → lands on /therapist/bookings
# Click "Availability" → edit working hours, add an override
# Sign out, sign in as admin@touchbase.local → /admin/* still works as before
```

View File

@@ -3,7 +3,8 @@ 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";
import { timeToMin, WEEKDAYS } from "@/lib/working-hours";
import { AvailabilityEditor } from "@/components/AvailabilityEditor";
export const metadata = { title: "Therapist availability — TouchBase" };
export const dynamic = "force-dynamic";
@@ -12,23 +13,11 @@ 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}`));
@@ -94,12 +83,6 @@ export default async function TherapistAvailabilityPage({
});
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
@@ -114,173 +97,15 @@ export default async function TherapistAvailabilityPage({
</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>
<AvailabilityEditor
tz={TZ}
workingHours={therapist.workingHours}
overrides={therapist.overrides}
saveWorkingHoursAction={saveWorkingHours}
addOverrideAction={addOverride}
deleteOverrideAction={deleteOverride}
hiddenFields={{ therapistId: id }}
/>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { redirect } from "next/navigation";
import { fromZonedTime } from "date-fns-tz";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { parseBool, parseStringTrim } from "@/lib/forms";
import { timeToMin, WEEKDAYS } from "@/lib/working-hours";
import { AvailabilityEditor } from "@/components/AvailabilityEditor";
export const metadata = { title: "My availability — TouchBase" };
export const dynamic = "force-dynamic";
const TZ = process.env.APP_TZ ?? "America/Detroit";
async function saveWorkingHours(formData: FormData): Promise<void> {
"use server";
// Therapist can ONLY edit their own — read id from session, ignore form values.
const session = await auth();
if (!session?.user || session.user.role !== "THERAPIST") {
redirect("/login?callbackUrl=/therapist/availability");
}
const therapistId = session!.user.id;
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("/therapist/availability");
}
async function addOverride(formData: FormData): Promise<void> {
"use server";
const session = await auth();
if (!session?.user || session.user.role !== "THERAPIST") {
redirect("/login?callbackUrl=/therapist/availability");
}
const therapistId = session!.user.id;
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("/therapist/availability");
}
async function deleteOverride(formData: FormData): Promise<void> {
"use server";
const session = await auth();
if (!session?.user || session.user.role !== "THERAPIST") {
redirect("/login?callbackUrl=/therapist/availability");
}
const id = parseStringTrim(formData.get("id"));
if (!id) return;
// Only allow deleting own overrides
const ov = await db.availabilityOverride.findUnique({
where: { id },
select: { therapistId: true },
});
if (!ov || ov.therapistId !== session!.user.id) {
redirect("/therapist/availability");
}
await db.availabilityOverride.delete({ where: { id } });
redirect("/therapist/availability");
}
export default async function MyAvailabilityPage() {
const session = await auth();
if (!session?.user || session.user.role !== "THERAPIST") {
redirect("/login?callbackUrl=/therapist/availability");
}
const userId = session!.user.id;
const therapist = await db.therapist.findUnique({
where: { userId },
include: {
user: { select: { name: true, email: true } },
workingHours: true,
overrides: { orderBy: { startsAt: "asc" } },
},
});
if (!therapist) {
return (
<div className="mx-auto max-w-md text-center">
<h1 className="mb-2 text-xl font-semibold tracking-tight">
Account not set up
</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Your user has the THERAPIST role but no therapist profile. Ask the
practice admin to complete your setup.
</p>
</div>
);
}
return (
<div className="mx-auto max-w-3xl">
<h1 className="mb-1 text-xl font-semibold tracking-tight">
My availability
</h1>
<p className="mb-6 text-sm text-zinc-500">{TZ}</p>
<AvailabilityEditor
tz={TZ}
workingHours={therapist.workingHours}
overrides={therapist.overrides}
saveWorkingHoursAction={saveWorkingHours}
addOverrideAction={addOverride}
deleteOverrideAction={deleteOverride}
/>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export const metadata = { title: "My schedule — TouchBase" };
export const dynamic = "force-dynamic";
const TZ = process.env.APP_TZ ?? "America/Detroit";
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);
}
export default async function TherapistBookingsPage() {
const session = await auth();
if (!session?.user || session.user.role !== "THERAPIST") {
redirect("/login?callbackUrl=/therapist/bookings");
}
const therapistId = session!.user.id;
const now = new Date();
const [upcoming, past] = await Promise.all([
db.booking.findMany({
where: {
therapistId,
status: { in: ["HOLD", "CONFIRMED"] },
startsAt: { gte: now },
},
include: {
customer: { select: { name: true, email: true, phone: true } },
service: { select: { name: true, durationMin: true } },
room: { select: { name: true } },
},
orderBy: { startsAt: "asc" },
}),
db.booking.findMany({
where: {
therapistId,
OR: [
{ startsAt: { lt: now } },
{ status: { in: ["CANCELLED", "COMPLETED", "NO_SHOW"] } },
],
},
include: {
customer: { select: { name: true, email: true } },
service: { select: { name: true } },
},
orderBy: { startsAt: "desc" },
take: 20,
}),
]);
return (
<div className="mx-auto max-w-4xl">
<h1 className="mb-6 text-xl font-semibold tracking-tight">My schedule</h1>
<section className="mb-8">
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Upcoming
</h2>
{upcoming.length === 0 ? (
<p className="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 upcoming appointments.
</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">When</th>
<th className="px-4 py-2 font-medium">Service</th>
<th className="px-4 py-2 font-medium">Customer</th>
<th className="px-4 py-2 font-medium">Room</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{upcoming.map((b) => (
<tr key={b.id}>
<td className="px-4 py-2 whitespace-nowrap font-mono text-xs">
{formatLocal(b.startsAt)}
</td>
<td className="px-4 py-2">
{b.service.name}
<div className="text-xs text-zinc-500">
{b.service.durationMin} min
</div>
</td>
<td className="px-4 py-2">
<div>{b.customer.name ?? <span className="italic text-zinc-500">unnamed</span>}</div>
<div className="text-xs text-zinc-500">{b.customer.email}</div>
{b.customer.phone && (
<div className="text-xs text-zinc-500">{b.customer.phone}</div>
)}
</td>
<td className="px-4 py-2">{b.room.name}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section>
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Recent
</h2>
{past.length === 0 ? (
<p className="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">
Nothing yet.
</p>
) : (
<ul className="grid gap-2">
{past.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>
<div className="font-medium">{b.service.name}</div>
<div className="text-xs text-zinc-500">
{formatLocal(b.startsAt)} · {b.customer.name ?? b.customer.email}
</div>
</div>
<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">
{b.status}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth, signOut } from "@/auth";
export const dynamic = "force-dynamic";
async function logout() {
"use server";
await signOut({ redirectTo: "/" });
}
export default async function TherapistLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect("/login?callbackUrl=/therapist");
if (session.user.role !== "THERAPIST") {
return (
<main className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Therapists only</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
This area is for therapists. {session.user.role === "ADMIN" ? (
<>Admins manage therapist availability via{" "}
<Link href="/admin/therapists" className="underline">
Admin Therapists
</Link>.
</>
) : (
<>If you book here, your bookings are at{" "}
<Link href="/account/bookings" className="underline">
My bookings
</Link>.
</>
)}
</p>
<form action={logout}>
<button
type="submit"
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700"
>
Sign out
</button>
</form>
</main>
);
}
return (
<div className="flex min-h-full flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-6 py-3 dark:border-zinc-800 dark:bg-zinc-950">
<div className="flex items-center gap-6">
<Link href="/therapist" className="text-sm font-semibold tracking-tight">
TouchBase
</Link>
<nav className="flex items-center gap-4 text-sm text-zinc-600 dark:text-zinc-400">
<Link href="/therapist/bookings" className="hover:text-zinc-900 dark:hover:text-zinc-100">
My schedule
</Link>
<Link href="/therapist/availability" className="hover:text-zinc-900 dark:hover:text-zinc-100">
Availability
</Link>
</nav>
</div>
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">
<span>{session.user.email}</span>
<form action={logout}>
<button
type="submit"
className="rounded-md border border-zinc-300 px-2.5 py-1 text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Sign out
</button>
</form>
</div>
</header>
<main className="flex-1 p-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function TherapistIndex() {
redirect("/therapist/bookings");
}

View File

@@ -0,0 +1,229 @@
// Reusable availability editor — used by admin (per-therapist) and therapist (own).
// Caller supplies the data + three server actions; the component renders the UI.
//
// All time values are in the practice TZ (`tz` prop). Overrides are stored as
// UTC instants in the DB; this component receives them as Date objects.
import { minToTime, WEEKDAYS } from "@/lib/working-hours";
export type WorkingHourEntry = { weekday: number; startMin: number; endMin: number };
export type OverrideEntry = {
id: string;
kind: "BLOCK" | "EXTRA_HOURS";
startsAt: Date;
endsAt: Date;
reason: string | null;
};
export function AvailabilityEditor({
tz,
workingHours,
overrides,
saveWorkingHoursAction,
addOverrideAction,
deleteOverrideAction,
hiddenFields, // extra hidden inputs for routes that need them (e.g. admin needs therapistId)
}: {
tz: string;
workingHours: WorkingHourEntry[];
overrides: OverrideEntry[];
saveWorkingHoursAction: (formData: FormData) => void | Promise<void>;
addOverrideAction: (formData: FormData) => void | Promise<void>;
deleteOverrideAction: (formData: FormData) => void | Promise<void>;
hiddenFields?: Record<string, string>;
}) {
const whByDay = new Map<number, { startMin: number; endMin: number }>();
for (const w of workingHours) {
whByDay.set(w.weekday, { startMin: w.startMin, endMin: w.endMin });
}
const formatLocal = (d: Date) =>
new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(d);
const renderHidden = () =>
hiddenFields
? Object.entries(hiddenFields).map(([k, v]) => (
<input key={k} type="hidden" name={k} value={v} />
))
: null;
return (
<>
{/* Weekly working hours */}
<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={saveWorkingHoursAction}
className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
{renderHidden()}
<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>
{overrides.length > 0 && (
<ul className="mb-4 grid gap-2">
{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={deleteOverrideAction}>
{renderHidden()}
<input type="hidden" name="id" value={o.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={addOverrideAction}
className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
{renderHidden()}
<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>
</>
);
}