Files
web/src/pages/Appointments.tsx
T
groombook-engineer[bot] 45ed3587ba feat: extract groombook/web from monorepo
- Copy apps/web/ with all src, components, pages, portal
- Inline packages/types/ as local packages/types module
- Add tsconfig path aliases for @groombook/types
- Port Dockerfile and CI workflow
- Image name: ghcr.io/groombook/web

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 21:38:42 +00:00

958 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<string, string[]> = {
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<Appointment[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [pets, setPets] = useState<Pet[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [staff, setStaff] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<BookingForm>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(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<Set<string | null>>(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<Appointment[]>;
})
.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<Client[]>;
}).then(setClients),
fetch("/api/services").then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Service[]>;
}).then(setServices),
fetch("/api/staff").then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Staff[]>;
}).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<Pet[]>)
.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<string, string>(
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<string, unknown> = {
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 <p style={{ padding: "1rem" }}>Loading</p>;
if (error) return <p style={{ padding: "1rem", color: "red" }}>Error: {error}</p>;
return (
<div style={{ fontFamily: "system-ui, sans-serif" }}>
{/* ── Header ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "1rem", flexWrap: "wrap" }}>
<h1 style={{ margin: 0 }}>Appointments</h1>
<button onClick={() => setWeekStart((w) => addDays(w, -7))} style={btnStyle}>
Prev
</button>
<span style={{ fontWeight: 600, fontSize: 14 }}>
{fmtDateShort(weekStart)} {fmtDateShort(weekEnd)}
</span>
<button onClick={() => setWeekStart((w) => addDays(w, 7))} style={btnStyle}>
Next
</button>
<button onClick={() => setWeekStart(startOfWeek(new Date()))} style={btnStyle}>
Today
</button>
<button
onClick={() => openNewForm()}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", marginLeft: "auto", borderColor: "var(--color-primary)" }}
>
+ New Appointment
</button>
</div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
</div>
</div>
)}
{/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
{(["status", "groomer"] as const).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
style={{
...btnStyle,
backgroundColor: viewMode === mode ? "#1e293b" : "#f9fafb",
color: viewMode === mode ? "#fff" : "#374151",
borderColor: viewMode === mode ? "#1e293b" : "#d1d5db",
}}
>
{mode === "status" ? "Status" : "Groomer"}
</button>
))}
{viewMode === "groomer" && (
<>
<span style={{ fontSize: 13, color: "#6b7280", marginLeft: "0.5rem" }}>Show:</span>
{activeGroomers.map((s) => {
const color = groomerColorMap.get(s.id) ?? UNASSIGNED_COLOR;
const visible = !hiddenGroomers.has(s.id);
return (
<button
key={s.id}
onClick={() => toggleGroomer(s.id)}
title={visible ? `Hide ${s.name}` : `Show ${s.name}`}
style={{
...btnStyle,
backgroundColor: visible ? color : "#f1f5f9",
color: visible ? "#fff" : "#94a3b8",
borderColor: visible ? color : "#e2e8f0",
display: "flex",
alignItems: "center",
gap: "0.3rem",
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : color, display: "inline-block" }} />
{s.name}
</button>
);
})}
{/* Unassigned toggle */}
{(() => {
const visible = !hiddenGroomers.has(null);
return (
<button
onClick={() => toggleGroomer(null)}
style={{
...btnStyle,
backgroundColor: visible ? UNASSIGNED_COLOR : "#f1f5f9",
color: visible ? "#fff" : "#94a3b8",
borderColor: visible ? UNASSIGNED_COLOR : "#e2e8f0",
display: "flex",
alignItems: "center",
gap: "0.3rem",
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: visible ? "#fff" : UNASSIGNED_COLOR, display: "inline-block" }} />
Unassigned
</button>
);
})()}
</>
)}
</div>
{/* ── Weekly Calendar ── */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "0.5rem" }}>
{days.map((day, i) => {
const isToday = formatDate(day) === formatDate(new Date());
return (
<div key={i} style={{ border: "1px solid #e5e7eb", borderRadius: 8, overflow: "hidden", minHeight: 180, background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<div
style={{
padding: "0.4rem 0.6rem",
background: isToday ? "linear-gradient(135deg, var(--color-primary), var(--color-primary-dark))" : "#f8fafc",
color: isToday ? "#fff" : "#374151",
fontWeight: 600,
fontSize: 12,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>{fmtDateShort(day)}</span>
<button
onClick={() => openNewForm(day)}
style={{
border: "none",
background: "transparent",
color: isToday ? "#fff" : "#6b7280",
cursor: "pointer",
fontSize: 16,
padding: 0,
lineHeight: 1,
}}
title="Add appointment"
>
+
</button>
</div>
<div style={{ padding: "0.3rem" }}>
{(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 (
<div
key={a.id}
onClick={() => 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,
}}
>
<div style={{ fontWeight: 600 }}>{fmtTime(a.startTime)}</div>
<div>{cli?.name ?? "—"}</div>
<div style={{ opacity: 0.9 }}>{svc?.name ?? "—"}</div>
{viewMode === "groomer" && (
<div style={{ opacity: 0.85, fontSize: 10 }}>
{groomer?.name ?? "Unassigned"}
</div>
)}
{a.seriesId && (
<div style={{ opacity: 0.85, fontSize: 10 }}> recurring</div>
)}
{a.confirmationStatus === "confirmed" && (
<div style={{ opacity: 0.95, fontSize: 10 }}> confirmed</div>
)}
{a.confirmationStatus === "cancelled" && (
<div style={{ opacity: 0.95, fontSize: 10, textDecoration: "line-through" }}> cust. cancelled</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
{/* ── Booking Form Modal ── */}
{showForm && (
<Modal onClose={() => setShowForm(false)}>
<h2 style={{ marginTop: 0 }}>New Appointment</h2>
<form onSubmit={submitBooking}>
<Field label="Client">
<select
value={form.clientId}
onChange={(e) => setForm((f) => ({ ...f, clientId: e.target.value }))}
required
style={inputStyle}
>
<option value=""> select client </option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</Field>
<Field label="Pet">
<select
value={form.petId}
onChange={(e) => setForm((f) => ({ ...f, petId: e.target.value }))}
required
disabled={!form.clientId}
style={inputStyle}
>
<option value=""> select pet </option>
{pets.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</Field>
<Field label="Service">
<select
value={form.serviceId}
onChange={(e) => setForm((f) => ({ ...f, serviceId: e.target.value }))}
required
style={inputStyle}
>
<option value=""> select service </option>
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.durationMinutes} min ${(s.basePriceCents / 100).toFixed(2)})
</option>
))}
</select>
</Field>
<Field label="Groomer (optional)">
<select
value={form.staffId}
onChange={(e) => setForm((f) => ({ ...f, staffId: e.target.value }))}
style={inputStyle}
>
<option value=""> any / unassigned </option>
{staff.filter((s) => s.active).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</Field>
<Field label="Bather / Assistant (optional)">
<select
value={form.batherStaffId}
onChange={(e) => setForm((f) => ({ ...f, batherStaffId: e.target.value }))}
style={inputStyle}
>
<option value=""> none </option>
{staff.filter((s) => s.active).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</Field>
<Field label="Date">
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
required
style={inputStyle}
/>
</Field>
<Field label="Start time">
<input
type="time"
value={form.startTime}
onChange={(e) => setForm((f) => ({ ...f, startTime: e.target.value }))}
required
style={inputStyle}
/>
</Field>
<Field label="Notes">
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
style={{ ...inputStyle, resize: "vertical" }}
/>
</Field>
{/* Recurrence */}
<div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer", fontSize: 13, fontWeight: 600, color: "#374151" }}>
<input
type="checkbox"
checked={form.recurring}
onChange={(e) => setForm((f) => ({ ...f, recurring: e.target.checked }))}
/>
Recurring appointment
</label>
</div>
{form.recurring && (
<div
style={{
background: "#f0f9ff",
border: "1px solid #bae6fd",
borderRadius: 6,
padding: "0.75rem",
marginBottom: "0.75rem",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem",
}}
>
<Field label="Repeat every">
<select
value={form.recurrenceFrequencyWeeks}
onChange={(e) => setForm((f) => ({ ...f, recurrenceFrequencyWeeks: e.target.value }))}
style={inputStyle}
>
<option value="2">2 weeks</option>
<option value="4">4 weeks</option>
<option value="6">6 weeks</option>
<option value="8">8 weeks</option>
<option value="12">12 weeks</option>
</select>
</Field>
<Field label="Number of appointments">
<input
type="number"
min={2}
max={52}
value={form.recurrenceCount}
onChange={(e) => setForm((f) => ({ ...f, recurrenceCount: e.target.value }))}
style={inputStyle}
/>
</Field>
</div>
)}
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving
? "Saving…"
: form.recurring
? `Book ${form.recurrenceCount} appointments`
: "Book Appointment"}
</button>
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>
Cancel
</button>
</div>
</form>
</Modal>
)}
{/* ── Appointment Detail Modal ── */}
{selectedAppt && (
<Modal onClose={() => setSelectedAppt(null)}>
<AppointmentDetail
appt={selectedAppt}
clients={clients}
services={services}
staff={staff}
onUpdateStatus={updateStatus}
onDelete={deleteAppt}
onClose={() => setSelectedAppt(null)}
/>
</Modal>
)}
</div>
);
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function AppointmentDetail({
appt,
clients,
services,
staff,
onUpdateStatus,
onDelete,
onClose,
}: {
appt: Appointment;
clients: Client[];
services: Service[];
staff: Staff[];
onUpdateStatus: (a: Appointment, status: string) => void;
onDelete: (id: string, cascade: CascadeMode) => void;
onClose: () => void;
}) {
const [showDeleteOptions, setShowDeleteOptions] = useState(false);
const [deleteCascade, setDeleteCascade] = useState<CascadeMode>("this_only");
const client = clients.find((c) => c.id === appt.clientId);
const service = services.find((s) => s.id === appt.serviceId);
const groomer = staff.find((s) => s.id === appt.staffId);
const bather = staff.find((s) => s.id === appt.batherStaffId);
const transitions = STATUS_TRANSITIONS[appt.status] ?? [];
function handleDeleteClick() {
if (appt.seriesId) {
setShowDeleteOptions(true);
} else {
if (confirm("Delete this appointment?")) {
onDelete(appt.id, "this_only");
}
}
}
return (
<>
<h2 style={{ marginTop: 0, display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
Appointment Details
{appt.seriesId && (
<span
style={{
fontSize: 12,
fontWeight: 500,
background: "#ede9fe",
color: "#6d28d9",
padding: "0.15rem 0.5rem",
borderRadius: 99,
}}
>
Recurring series
</span>
)}
</h2>
<table style={{ borderCollapse: "collapse", width: "100%", marginBottom: "1rem", fontSize: 14 }}>
<tbody>
{([
["Client", client?.name ?? "—"],
["Service", service?.name ?? "—"],
["Groomer", groomer?.name ?? "Unassigned"],
...(bather ? [["Bather/Asst.", bather.name] as [string, string]] : []),
["Start", new Date(appt.startTime).toLocaleString()],
["End", new Date(appt.endTime).toLocaleString()],
["Status", appt.status.replace("_", " ")],
["Confirmation", appt.confirmationStatus === "confirmed"
? `✓ Confirmed${appt.confirmedAt ? ` (${new Date(appt.confirmedAt).toLocaleString()})` : ""}`
: appt.confirmationStatus === "cancelled"
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
: "Pending"],
["Notes", appt.notes ?? "—"],
...(appt.customerNotes ? [["Customer Notes", appt.customerNotes] as [string, string]] : []),
...(appt.seriesId
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
: []),
] as [string, string][]).map(([label, value]) => (
<tr key={label}>
<td style={{ padding: "4px 12px 4px 0", fontWeight: 600, whiteSpace: "nowrap", verticalAlign: "top", color: "#6b7280" }}>
{label}
</td>
<td style={{ padding: "4px 0" }}>{value}</td>
</tr>
))}
</tbody>
</table>
{transitions.length > 0 && (
<div style={{ marginBottom: "0.75rem" }}>
<span style={{ fontWeight: 600, fontSize: 13, marginRight: "0.5rem" }}>Move to:</span>
{transitions.map((s) => (
<button
key={s}
onClick={() => onUpdateStatus(appt, s)}
style={{
...btnStyle,
backgroundColor: STATUS_COLORS[s],
color: "#fff",
borderColor: STATUS_COLORS[s],
marginRight: "0.4rem",
marginBottom: "0.3rem",
}}
>
{s.replace("_", " ")}
</button>
))}
</div>
)}
{/* Cascade delete picker (series appointments only) */}
{showDeleteOptions && (
<div
style={{
background: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: 6,
padding: "0.75rem",
marginBottom: "0.75rem",
}}
>
<p style={{ margin: "0 0 0.5rem", fontWeight: 600, fontSize: 13 }}>
This is part of a recurring series. Which appointments should be cancelled?
</p>
{(
[
["this_only", "This appointment only"],
["this_and_future", "This and all future appointments in the series"],
["all", "All appointments in the series"],
] as [CascadeMode, string][]
).map(([value, label]) => (
<label
key={value}
style={{ display: "flex", alignItems: "center", gap: "0.4rem", marginBottom: "0.35rem", fontSize: 13, cursor: "pointer" }}
>
<input
type="radio"
name="deleteCascade"
value={value}
checked={deleteCascade === value}
onChange={() => setDeleteCascade(value)}
/>
{label}
</label>
))}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
onClick={() => onDelete(appt.id, deleteCascade)}
style={{ ...btnStyle, backgroundColor: "#ef4444", color: "#fff", borderColor: "#ef4444" }}
>
Confirm cancellation
</button>
<button onClick={() => setShowDeleteOptions(false)} style={btnStyle}>
Cancel
</button>
</div>
</div>
)}
{!showDeleteOptions && (
<div style={{ display: "flex", gap: "0.5rem" }}>
{appt.status !== "completed" && appt.status !== "cancelled" && (
<button
onClick={handleDeleteClick}
style={{ ...btnStyle, backgroundColor: "#ef4444", color: "#fff", borderColor: "#ef4444" }}
>
Delete
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
</>
);
}
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement;
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
const firstFocusable = focusableElements?.[0];
firstFocusable?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
if (!modalRef.current) return;
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 100,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={modalRef}
style={{
background: "#fff",
borderRadius: 8,
padding: "1.5rem",
maxWidth: 500,
width: "calc(100% - 2rem)",
maxHeight: "90vh",
overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
{children}
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }}>
{label}
</label>
{children}
</div>
);
}
const btnStyle: React.CSSProperties = {
padding: "0.4rem 0.85rem",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.45rem 0.6rem",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
boxSizing: "border-box",
};