phase B therapist self-serve: /therapist (schedule + availability)
This commit is contained in:
86
docs/progress/2026-05-02-therapist-self-serve.md
Normal file
86
docs/progress/2026-05-02-therapist-self-serve.md
Normal 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
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
133
src/app/therapist/availability/page.tsx
Normal file
133
src/app/therapist/availability/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/app/therapist/bookings/page.tsx
Normal file
142
src/app/therapist/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/app/therapist/layout.tsx
Normal file
81
src/app/therapist/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/therapist/page.tsx
Normal file
5
src/app/therapist/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function TherapistIndex() {
|
||||
redirect("/therapist/bookings");
|
||||
}
|
||||
229
src/components/AvailabilityEditor.tsx
Normal file
229
src/components/AvailabilityEditor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user