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">
|
||||
Bookings
|
||||
</Link>
|
||||
<Link href="/admin/services" className="hover:text-zinc-900 dark:hover:text-zinc-100">
|
||||
Services
|
||||
</Link>
|
||||
<Link href="/admin/rooms" className="hover:text-zinc-900 dark:hover:text-zinc-100">
|
||||
Rooms
|
||||
</Link>
|
||||
<Link href="/admin/therapists" className="hover:text-zinc-900 dark:hover:text-zinc-100">
|
||||
Therapists
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
|
||||
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