import { useEffect, useState, useCallback, useRef } from "react"; import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types"; // ─── Helpers ──────────────────────────────────────────────────────────────── function startOfWeek(date: Date): Date { const d = new Date(date); const day = d.getDay(); // 0=Sun const diff = day === 0 ? -6 : 1 - day; // Monday start d.setDate(d.getDate() + diff); d.setHours(0, 0, 0, 0); return d; } function addDays(date: Date, n: number): Date { const d = new Date(date); d.setDate(d.getDate() + n); return d; } function formatDate(d: Date): string { return d.toISOString().slice(0, 10); } function fmtTime(iso: string): string { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function fmtDateShort(d: Date): string { return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); } const STATUS_COLORS: Record = { scheduled: "#3b82f6", confirmed: "#10b981", in_progress: "#f59e0b", completed: "#6b7280", cancelled: "#ef4444", 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"], in_progress: ["completed", "no_show"], completed: [], cancelled: [], no_show: [], }; // ─── Types ─────────────────────────────────────────────────────────────────── type CascadeMode = "this_only" | "this_and_future" | "all"; interface BookingForm { clientId: string; petId: string; serviceId: string; staffId: string; batherStaffId: string; date: string; startTime: string; notes: string; recurring: boolean; recurrenceFrequencyWeeks: string; recurrenceCount: string; } const EMPTY_FORM: BookingForm = { clientId: "", petId: "", serviceId: "", staffId: "", batherStaffId: "", date: formatDate(new Date()), startTime: "09:00", notes: "", recurring: false, recurrenceFrequencyWeeks: "4", recurrenceCount: "12", }; // ─── Component ─────────────────────────────────────────────────────────────── export function AppointmentsPage() { const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date())); const [appointments, setAppointments] = useState([]); const [clients, setClients] = useState([]); const [pets, setPets] = useState([]); const [services, setServices] = useState([]); const [staff, setStaff] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); const [form, setForm] = useState(EMPTY_FORM); 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 [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null); const weekEnd = addDays(weekStart, 6); useEffect(() => { fetch("/api/invoices/stats/summary") .then((r) => r.ok ? r.json() : null) .then((data) => { if (data) setPaymentStats(data); }) .catch(() => {}); }, []); const loadAppointments = useCallback(() => { const from = weekStart.toISOString(); const to = addDays(weekStart, 7).toISOString(); return fetch(`/api/appointments?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`) .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }) .then(setAppointments); }, [weekStart]); useEffect(() => { setLoading(true); setError(null); Promise.all([ loadAppointments(), fetch("/api/clients").then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }).then(setClients), fetch("/api/services").then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }).then(setServices), fetch("/api/staff").then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }).then(setStaff), ]) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error")) .finally(() => setLoading(false)); }, [loadAppointments]); // Load pets when client is selected useEffect(() => { if (!form.clientId) { setPets([]); setForm((f) => ({ ...f, petId: "" })); return; } fetch(`/api/pets?clientId=${encodeURIComponent(form.clientId)}`) .then((r) => r.json() as Promise) .then(setPets); }, [form.clientId]); 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); 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) { setForm({ ...EMPTY_FORM, date: formatDate(date ?? new Date()) }); setFormError(null); setShowForm(true); } async function submitBooking(e: React.FormEvent) { e.preventDefault(); if (!form.clientId || !form.petId || !form.serviceId) { setFormError("Client, pet, and service are required."); return; } const service = services.find((s) => s.id === form.serviceId); if (!service) return; const startISO = new Date(`${form.date}T${form.startTime}`).toISOString(); const endDate = new Date(`${form.date}T${form.startTime}`); endDate.setMinutes(endDate.getMinutes() + service.durationMinutes); const endISO = endDate.toISOString(); const payload: Record = { clientId: form.clientId, petId: form.petId, serviceId: form.serviceId, staffId: form.staffId || undefined, batherStaffId: form.batherStaffId || undefined, startTime: startISO, endTime: endISO, notes: form.notes || undefined, }; if (form.recurring) { payload.recurrence = { frequencyWeeks: parseInt(form.recurrenceFrequencyWeeks), count: parseInt(form.recurrenceCount), }; } setSaving(true); setFormError(null); try { const res = await fetch("/api/appointments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? `HTTP ${res.status}`); } setShowForm(false); await loadAppointments(); } catch (e: unknown) { setFormError(e instanceof Error ? e.message : "Failed to save"); } finally { setSaving(false); } } async function updateStatus(appt: Appointment, status: string) { try { const res = await fetch(`/api/appointments/${appt.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setSelectedAppt(null); await loadAppointments(); } catch (e: unknown) { alert(e instanceof Error ? e.message : "Failed to update"); } } async function deleteAppt(id: string, cascade: CascadeMode) { const url = cascade !== "this_only" ? `/api/appointments/${id}?cascade=${cascade}` : `/api/appointments/${id}`; try { const res = await fetch(url, { method: "DELETE" }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? `HTTP ${res.status}`); } } catch (e: unknown) { alert(e instanceof Error ? e.message : "Failed to delete appointment"); } setSelectedAppt(null); await loadAppointments(); } if (loading) return

Loading…

; if (error) return

Error: {error}

; return (
{/* ── Header ── */}

Appointments

{fmtDateShort(weekStart)} – {fmtDateShort(weekEnd)}
{/* Payment Stats Summary */} {paymentStats && (
Revenue (paid)
${(paymentStats.revenueThisMonth / 100).toFixed(2)}
Outstanding
${(paymentStats.outstanding / 100).toFixed(2)}
Refunds (this mo.)
${(paymentStats.refundsThisMonth / 100).toFixed(2)}
)} {/* ── 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) => { const isToday = formatDate(day) === formatDate(new Date()); return (
{fmtDateShort(day)}
{(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: apptColor(a), color: "#fff", borderRadius: 4, padding: "0.2rem 0.35rem", marginBottom: "0.2rem", fontSize: 11, cursor: "pointer", lineHeight: 1.4, }} >
{fmtTime(a.startTime)}
{cli?.name ?? "—"}
{svc?.name ?? "—"}
{viewMode === "groomer" && (
{groomer?.name ?? "Unassigned"}
)} {a.seriesId && (
↻ recurring
)} {a.confirmationStatus === "confirmed" && (
✓ confirmed
)} {a.confirmationStatus === "cancelled" && (
✗ cust. cancelled
)}
); })}
); })}
{/* ── Booking Form Modal ── */} {showForm && ( setShowForm(false)}>

New Appointment

setForm((f) => ({ ...f, date: e.target.value }))} required style={inputStyle} /> setForm((f) => ({ ...f, startTime: e.target.value }))} required style={inputStyle} />