From 9b2c177d80e98c272362a868b63cd06758e9136f Mon Sep 17 00:00:00 2001 From: goetchstone Date: Fri, 17 Jul 2026 08:18:36 -0400 Subject: [PATCH] feat: add booking availability editor Hours are StaffAvailability rows and setAvailability had no caller, so booking times could only be changed by direct DB access. The editor scopes to one service to match availableSlots union semantics. --- app/(dashboard)/dashboard/scheduling/page.tsx | 2 + components/scheduling/availability-editor.tsx | 168 ++++++++++++++++++ server/trpc/routers/scheduling.ts | 11 ++ 3 files changed, 181 insertions(+) create mode 100644 components/scheduling/availability-editor.tsx diff --git a/app/(dashboard)/dashboard/scheduling/page.tsx b/app/(dashboard)/dashboard/scheduling/page.tsx index 8143b67..37d0022 100644 --- a/app/(dashboard)/dashboard/scheduling/page.tsx +++ b/app/(dashboard)/dashboard/scheduling/page.tsx @@ -3,6 +3,7 @@ export const dynamic = "force-dynamic"; import { createCachedCaller } from "@/lib/trpc/server"; import { AppointmentList } from "@/components/scheduling/appointment-list"; +import { AvailabilityEditor } from "@/components/scheduling/availability-editor"; export default async function SchedulingPage() { const caller = await createCachedCaller(); @@ -16,6 +17,7 @@ export default async function SchedulingPage() { Upcoming and recent appointments.

+ ); diff --git a/components/scheduling/availability-editor.tsx b/components/scheduling/availability-editor.tsx new file mode 100644 index 0000000..eee913b --- /dev/null +++ b/components/scheduling/availability-editor.tsx @@ -0,0 +1,168 @@ +// components/scheduling/availability-editor.tsx +"use client"; + +import { useEffect, useState } from "react"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Check, Loader2 } from "lucide-react"; + +// Index === dayOfWeek, matching JS Date.getDay() (0 = Sunday), which is what +// availableSlots compares against. +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +interface DayRow { + enabled: boolean; + startTime: string; + endTime: string; +} + +const CLOSED: DayRow = { enabled: false, startTime: "09:00", endTime: "17:00" }; + +export function AvailabilityEditor() { + const services = trpc.scheduling.listServices.useQuery({ publicOnly: false }); + const [serviceId, setServiceId] = useState(""); + + // Availability is per-service, and availableSlots unions service-scoped rows + // with global ones — so the editor always targets one explicit service rather + // than writing "global" hours that would silently add slots on top. + useEffect(() => { + const first = services.data?.[0]; + if (!serviceId && first) setServiceId(first.id); + }, [services.data, serviceId]); + + const availability = trpc.scheduling.getAvailability.useQuery( + { serviceId: serviceId || undefined }, + { enabled: serviceId.length > 0 } + ); + + const [rows, setRows] = useState(() => DAYS.map(() => ({ ...CLOSED }))); + + useEffect(() => { + if (!availability.data) return; + const next = DAYS.map(() => ({ ...CLOSED })); + for (const a of availability.data) { + if (a.dayOfWeek >= 0 && a.dayOfWeek <= 6) { + next[a.dayOfWeek] = { enabled: true, startTime: a.startTime, endTime: a.endTime }; + } + } + setRows(next); + }, [availability.data]); + + const utils = trpc.useUtils(); + const save = trpc.scheduling.setAvailability.useMutation({ + onSuccess: () => utils.scheduling.getAvailability.invalidate(), + }); + + function update(index: number, patch: Partial) { + setRows((rs) => rs.map((r, i) => (i === index ? { ...r, ...patch } : r))); + } + + // Lexical compare is safe on zero-padded HH:MM and catches the end-before-start + // case that would otherwise publish a window producing zero slots. + const badRange = rows.some((r) => r.enabled && r.startTime >= r.endTime); + + function onSave() { + if (!serviceId || badRange) return; + save.mutate({ + serviceId, + slots: rows.flatMap((r, dayOfWeek) => + r.enabled ? [{ dayOfWeek, startTime: r.startTime, endTime: r.endTime }] : [] + ), + }); + } + + return ( +
+
+

Booking availability

+ {services.data && services.data.length > 1 && ( + + )} +
+

+ Unchecked days show no times on the public booking page. Times are Eastern + (America/New_York) and are converted per visitor. +

+ + {availability.isLoading ? ( +

Loading…

+ ) : ( +
+ {DAYS.map((day, i) => { + const row = rows[i]!; + return ( +
+ + + {row.enabled ? ( +
+ + update(i, { startTime: e.target.value })} + className="w-32" + /> + to + + update(i, { endTime: e.target.value })} + className="w-32" + /> + {row.startTime >= row.endTime && ( + End must be after start + )} +
+ ) : ( + Closed + )} +
+ ); + })} +
+ )} + +
+ + {save.isSuccess && !save.isPending && ( + + Saved + + )} + {save.error && {save.error.message}} +
+
+ ); +} diff --git a/server/trpc/routers/scheduling.ts b/server/trpc/routers/scheduling.ts index 3c179cc..025628f 100644 --- a/server/trpc/routers/scheduling.ts +++ b/server/trpc/routers/scheduling.ts @@ -88,6 +88,17 @@ export const schedulingRouter = createTRPCRouter({ // ── Availability ───────────────────────────────────────────────────────── + getAvailability: staffProcedure + .input(z.object({ serviceId: z.string().cuid().optional() })) + .query(({ ctx, input }) => + ctx.db.staffAvailability.findMany({ + // Scope must mirror setAvailability's replace-scope exactly, or the + // editor would show one set of hours and overwrite a different one. + where: { userId: ctx.session.user.id, serviceId: input.serviceId ?? null }, + orderBy: { dayOfWeek: "asc" }, + }) + ), + setAvailability: staffProcedure .input( z.object({