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({