initial ux
This commit is contained in:
102
docs/progress/2026-05-02-admin-crud.md
Normal file
102
docs/progress/2026-05-02-admin-crud.md
Normal 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 Tue–Fri 10–19 for all therapists" or "block Room 7 every Sunday." Defer until someone asks for it.
|
||||||
|
5. **NEW**: audit logging for admin actions. We have an `AuditLog` table but the new CRUD actions don't write to it. Worth wiring up before launch.
|
||||||
|
|
||||||
|
## Roadmap status
|
||||||
|
|
||||||
|
UX-completeness phases (the user's "complete the rest of the UX" pivot before payments):
|
||||||
|
|
||||||
|
- **A — Admin CRUD: done 2026-05-02 (this session)**
|
||||||
|
- B — Therapist self-serve at `/therapist` — own working hours + time-off
|
||||||
|
- C — Customer reschedule + admin "mark complete/no-show" + booking detail pages
|
||||||
|
- D — PWA shell (manifest + service worker) per Initial.md §11
|
||||||
|
- E — Polish — 404, empty states, mobile-first review, dark-mode glance
|
||||||
|
|
||||||
|
Backend roadmap unchanged: 5d Stripe → 5e reminders, after UX is rounded out.
|
||||||
|
|
||||||
|
## Recommended next step
|
||||||
|
|
||||||
|
**Phase B — therapist self-serve**. The smallest remaining UX chunk that's actually load-bearing. Per `Initial.md` decision log, therapists set their own availability; admin is the override path, not the primary path. Implementation:
|
||||||
|
|
||||||
|
- `/therapist` layout that requires `role=THERAPIST`
|
||||||
|
- `/therapist/availability` — same page as admin's, scoped to the signed-in therapist
|
||||||
|
- `/therapist/bookings` — read-only list of upcoming bookings on this therapist
|
||||||
|
- Reuse the existing form helpers; no new infrastructure
|
||||||
|
|
||||||
|
~half-day. After B, Phase C (~1 day) → D (~1 day) → E (~half-day) and we're UX-complete for v1 minus payments.
|
||||||
|
|
||||||
|
## How to resume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/noise/Documents/code/touchbase
|
||||||
|
docker-compose up -d postgres mailpit
|
||||||
|
pnpm db:seed
|
||||||
|
pnpm dev
|
||||||
|
# Sign in as admin@touchbase.local at /login
|
||||||
|
# Click around: /admin/services, /admin/rooms, /admin/therapists, etc.
|
||||||
|
# Try: create a new service, add a tag, deactivate it, edit a therapist's working hours, add an override, add a room block.
|
||||||
|
```
|
||||||
@@ -46,6 +46,15 @@ export default async function AdminLayout({
|
|||||||
<Link href="/admin/bookings" className="hover:text-zinc-900 dark:hover:text-zinc-100">
|
<Link href="/admin/bookings" className="hover:text-zinc-900 dark:hover:text-zinc-100">
|
||||||
Bookings
|
Bookings
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">
|
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
|||||||
158
src/app/admin/rooms/[id]/blocks/page.tsx
Normal file
158
src/app/admin/rooms/[id]/blocks/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/admin/rooms/[id]/page.tsx
Normal file
57
src/app/admin/rooms/[id]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/app/admin/rooms/_form.tsx
Normal file
98
src/app/admin/rooms/_form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/admin/rooms/new/page.tsx
Normal file
28
src/app/admin/rooms/new/page.tsx
Normal 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" />
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/app/admin/rooms/page.tsx
Normal file
92
src/app/admin/rooms/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/admin/services/[id]/page.tsx
Normal file
70
src/app/admin/services/[id]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/app/admin/services/_form.tsx
Normal file
200
src/app/admin/services/_form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/admin/services/new/page.tsx
Normal file
43
src/app/admin/services/new/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/app/admin/services/page.tsx
Normal file
86
src/app/admin/services/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
src/app/admin/therapists/[id]/availability/page.tsx
Normal file
286
src/app/admin/therapists/[id]/availability/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/app/admin/therapists/[id]/page.tsx
Normal file
87
src/app/admin/therapists/[id]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/app/admin/therapists/_form.tsx
Normal file
178
src/app/admin/therapists/_form.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/app/admin/therapists/new/page.tsx
Normal file
62
src/app/admin/therapists/new/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/admin/therapists/page.tsx
Normal file
95
src/app/admin/therapists/page.tsx
Normal 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
40
src/lib/forms.ts
Normal 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
32
src/lib/working-hours.ts
Normal 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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user