From 93b738b613d6c2b3ab65f0255de1c0c17868fb4f Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Tue, 17 Mar 2026 21:53:49 +0000 Subject: [PATCH] feat: multi-groomer calendar view with per-groomer filtering Add groomer view mode to the appointments calendar: - Toggle between "Status" (existing) and "Groomer" color coding - Per-groomer visibility toggles with color-coded buttons - Appointments colored by assigned groomer in groomer view - Groomer name shown on appointment blocks in groomer view - Unassigned appointments shown in neutral gray Satisfies groombook/groombook#11 requirements for side-by-side/unified groomer schedule visibility and per-groomer filter/toggle. Co-Authored-By: Paperclip --- apps/web/src/pages/Appointments.tsx | 120 +++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index e9313ad..8aee4cf 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -39,6 +39,18 @@ const STATUS_COLORS: Record = { no_show: "#9ca3af", }; +const GROOMER_PALETTE = [ + "#8b5cf6", // violet + "#0ea5e9", // sky + "#f43f5e", // rose + "#14b8a6", // teal + "#f97316", // orange + "#a855f7", // purple + "#84cc16", // lime + "#e879f9", // fuchsia +]; +const UNASSIGNED_COLOR = "#94a3b8"; + const STATUS_TRANSITIONS: Record = { scheduled: ["confirmed", "cancelled", "no_show"], confirmed: ["in_progress", "cancelled", "no_show"], @@ -94,6 +106,10 @@ export function AppointmentsPage() { const [formError, setFormError] = useState(null); const [saving, setSaving] = useState(false); const [selectedAppt, setSelectedAppt] = useState(null); + // Groomer view state + const [viewMode, setViewMode] = useState<"status" | "groomer">("status"); + // null key = unassigned; staffId string = that groomer; undefined set = all visible + const [hiddenGroomers, setHiddenGroomers] = useState>(new Set()); const weekEnd = addDays(weekStart, 6); @@ -135,9 +151,35 @@ export function AppointmentsPage() { const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)); + // Assign a stable color to each active groomer by index + const activeGroomers = staff.filter((s) => s.active && s.role === "groomer"); + const groomerColorMap = new Map( + activeGroomers.map((s, i) => [s.id, GROOMER_PALETTE[i % GROOMER_PALETTE.length] ?? UNASSIGNED_COLOR]) + ); + + function groomerColor(staffId: string | null): string { + if (!staffId) return UNASSIGNED_COLOR; + return groomerColorMap.get(staffId) ?? UNASSIGNED_COLOR; + } + + function apptColor(a: Appointment): string { + return viewMode === "groomer" ? groomerColor(a.staffId) : (STATUS_COLORS[a.status] ?? "#94a3b8"); + } + + function toggleGroomer(key: string | null) { + setHiddenGroomers((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + const apptsByDay = days.map((day) => { const dateStr = formatDate(day); - return appointments.filter((a) => a.startTime.startsWith(dateStr)); + const dayAppts = appointments.filter((a) => a.startTime.startsWith(dateStr)); + if (viewMode !== "groomer" || hiddenGroomers.size === 0) return dayAppts; + return dayAppts.filter((a) => !hiddenGroomers.has(a.staffId)); }); function openNewForm(date?: Date) { @@ -252,6 +294,74 @@ export function AppointmentsPage() { + {/* ── View Mode + Groomer Filters ── */} +
+ Color by: + {(["status", "groomer"] as const).map((mode) => ( + + ))} + {viewMode === "groomer" && ( + <> + Show: + {activeGroomers.map((s) => { + const color = groomerColorMap.get(s.id) ?? UNASSIGNED_COLOR; + const visible = !hiddenGroomers.has(s.id); + return ( + + ); + })} + {/* Unassigned toggle */} + {(() => { + const visible = !hiddenGroomers.has(null); + return ( + + ); + })()} + + )} +
+ {/* ── Weekly Calendar ── */}
{days.map((day, i) => { @@ -291,12 +401,13 @@ export function AppointmentsPage() { {(apptsByDay[i] ?? []).map((a) => { const svc = services.find((s) => s.id === a.serviceId); const cli = clients.find((c) => c.id === a.clientId); + const groomer = staff.find((s) => s.id === a.staffId); return (
setSelectedAppt(a)} style={{ - background: STATUS_COLORS[a.status] ?? "#94a3b8", + background: apptColor(a), color: "#fff", borderRadius: 4, padding: "0.2rem 0.35rem", @@ -309,6 +420,11 @@ export function AppointmentsPage() {
{fmtTime(a.startTime)}
{cli?.name ?? "—"}
{svc?.name ?? "—"}
+ {viewMode === "groomer" && ( +
+ {groomer?.name ?? "Unassigned"} +
+ )} {a.seriesId && (
↻ recurring
)}