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>
This commit is contained in:
groombook-engineer[bot]
2026-05-02 21:38:42 +00:00
parent e03d052ec6
commit 45ed3587ba
131 changed files with 22602 additions and 0 deletions
+957
View File
@@ -0,0 +1,957 @@
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",
};
+612
View File
@@ -0,0 +1,612 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { Service } from "@groombook/types";
// ─── Types ───────────────────────────────────────────────────────────────────
interface BookingBody {
serviceId: string;
startTime: string;
clientName: string;
clientEmail: string;
clientPhone: string;
petName: string;
petSpecies: string;
petBreed: string;
notes: string;
}
interface BookingResult {
appointment: { id: string; startTime: string; endTime: string };
client: { id: string; name: string; email: string | null };
pet: { id: string; name: string };
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
function fmtDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDateLong(isoDate: string): string {
const d = new Date(isoDate + "T12:00:00Z");
return d.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StepIndicator({ step }: { step: number }) {
const steps = ["Service", "Date & Time", "Your Info", "Confirm"];
return (
<div style={{ display: "flex", gap: 0, marginBottom: "1.5rem" }}>
{steps.map((label, i) => {
const idx = i + 1;
const active = idx === step;
const done = idx < step;
return (
<div
key={label}
style={{
flex: 1,
textAlign: "center",
padding: "0.5rem 0.25rem",
fontSize: 12,
fontWeight: active ? 700 : 400,
color: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af",
borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: "50%",
background: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb",
color: active || done ? "#fff" : "#6b7280",
fontSize: 12,
fontWeight: 700,
marginRight: 4,
}}
>
{done ? "✓" : idx}
</span>
{label}
</div>
);
})}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function BookPage() {
const [step, setStep] = useState(1);
// Step 1 — service
const [services, setServices] = useState<Service[]>([]);
const [servicesLoading, setServicesLoading] = useState(true);
const [selectedService, setSelectedService] = useState<Service | null>(null);
// Step 2 — date & time
const [date, setDate] = useState(todayIso());
const [dateError, setDateError] = useState<string | null>(null);
const [slots, setSlots] = useState<string[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
// Step 3 — contact info
const [form, setForm] = useState<BookingBody>({
serviceId: "",
startTime: "",
clientName: "",
clientEmail: "",
clientPhone: "",
petName: "",
petSpecies: "",
petBreed: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
// Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com)
const [searchParams] = useSearchParams();
useEffect(() => {
const clientName = searchParams.get("clientName");
const clientEmail = searchParams.get("clientEmail");
const clientPhone = searchParams.get("clientPhone");
const petName = searchParams.get("petName");
const petSpecies = searchParams.get("petSpecies");
const petBreed = searchParams.get("petBreed");
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) {
setForm((f) => ({
...f,
...(clientName && { clientName }),
...(clientEmail && { clientEmail }),
...(clientPhone && { clientPhone }),
...(petName && { petName }),
...(petSpecies && { petSpecies }),
...(petBreed && { petBreed }),
}));
}
}, [searchParams]);
// Step 4 — result
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<BookingResult | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Load services on mount
useEffect(() => {
fetch("/api/book/services")
.then((r) => r.json() as Promise<Service[]>)
.then(setServices)
.catch(() => setServices([]))
.finally(() => setServicesLoading(false));
}, []);
// Load slots when service or date changes (step 2)
useEffect(() => {
if (!selectedService || !date) return;
setSlotsLoading(true);
setSelectedSlot(null);
fetch(
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date]);
function goToStep2(svc: Service) {
setSelectedService(svc);
setForm((f) => ({ ...f, serviceId: svc.id }));
setStep(2);
}
function goToStep3() {
if (!selectedSlot) return;
setForm((f) => ({ ...f, startTime: selectedSlot }));
setStep(3);
}
function goToStep4() {
if (!form.clientName.trim() || !form.clientEmail.trim() || !form.petName.trim() || !form.petSpecies.trim()) {
setFormError("Please fill in all required fields.");
return;
}
setFormError(null);
setStep(4);
}
async function submitBooking() {
setSubmitting(true);
setSubmitError(null);
try {
const res = await fetch("/api/book/appointments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serviceId: form.serviceId,
startTime: form.startTime,
clientName: form.clientName,
clientEmail: form.clientEmail,
clientPhone: form.clientPhone || undefined,
petName: form.petName,
petSpecies: form.petSpecies,
petBreed: form.petBreed || undefined,
notes: form.notes || undefined,
}),
});
if (!res.ok) {
const body = (await res.json()) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as BookingResult;
setResult(data);
setStep(5);
} catch (e: unknown) {
setSubmitError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
} finally {
setSubmitting(false);
}
}
// ── Styles ──
const card: React.CSSProperties = {
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "1rem",
cursor: "pointer",
};
const selectedCard: React.CSSProperties = {
...card,
border: "2px solid var(--color-primary)",
background: "#f0faf5",
};
const input: React.CSSProperties = {
width: "100%",
padding: "0.5rem 0.75rem",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
boxSizing: "border-box",
};
const label: React.CSSProperties = {
display: "block",
fontSize: 13,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
};
const btn: React.CSSProperties = {
padding: "0.6rem 1.25rem",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
};
const primaryBtn: React.CSSProperties = {
...btn,
background: "var(--color-primary)",
color: "#fff",
};
const secondaryBtn: React.CSSProperties = {
...btn,
background: "#f3f4f6",
color: "#374151",
};
return (
<div style={{ maxWidth: 640, margin: "0 auto", padding: "1rem" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>
Book an Appointment
</h1>
<p style={{ fontSize: 14, color: "#6b7280", marginTop: 4 }}>
Schedule a grooming appointment for your pet in minutes.
</p>
</div>
{step < 5 && <StepIndicator step={step} />}
{/* ── Step 1: Select Service ── */}
{step === 1 && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "0.75rem" }}>
Choose a service
</h2>
{servicesLoading && <p style={{ color: "#6b7280" }}>Loading services</p>}
{!servicesLoading && services.length === 0 && (
<p style={{ color: "#ef4444" }}>No services available. Please contact us to book.</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{services.map((svc) => (
<div
key={svc.id}
style={selectedService?.id === svc.id ? selectedCard : card}
onClick={() => goToStep2(svc)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && goToStep2(svc)}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontWeight: 600, fontSize: 15, color: "#1f2937" }}>{svc.name}</div>
{svc.description && (
<div style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>{svc.description}</div>
)}
</div>
<div style={{ textAlign: "right", flexShrink: 0, marginLeft: "1rem" }}>
<div style={{ fontWeight: 700, color: "var(--color-primary)", fontSize: 15 }}>
{fmtPrice(svc.basePriceCents)}
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{fmtDuration(svc.durationMinutes)}</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* ── Step 2: Date & Time ── */}
{step === 2 && selectedService && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>Choose a date and time</h2>
<p style={{ fontSize: 13, color: "#6b7280", marginBottom: "1rem" }}>
{selectedService.name} {fmtDuration(selectedService.durationMinutes)} {fmtPrice(selectedService.basePriceCents)}
</p>
<div style={{ marginBottom: "1rem" }}>
<label style={label}>Date</label>
<input
type="date"
value={date}
min={todayIso()}
style={{ ...input, width: "auto" }}
onChange={(e) => {
const val = e.target.value;
// HTML5 date input enforces yyyy-MM-dd; empty value means invalid format
if (!val) {
setDateError("Please enter a valid date (YYYY-MM-DD).");
setDate("");
} else {
setDateError(null);
setDate(val);
}
}}
/>
{dateError && (
<p style={{ color: "#dc2626", fontSize: 12, marginTop: 4 }}>{dateError}</p>
)}
</div>
<div style={{ marginBottom: "1.25rem" }}>
<label style={label}>Available times on {fmtDateLong(date)}</label>
{slotsLoading && <p style={{ color: "#6b7280", fontSize: 13 }}>Checking availability</p>}
{!slotsLoading && slots.length === 0 && (
<p style={{ color: "#6b7280", fontSize: 13 }}>
No available slots on this date. Please try another day.
</p>
)}
{!slotsLoading && slots.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" }}>
{slots.map((slot) => (
<button
key={slot}
onClick={() => setSelectedSlot(slot)}
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: `2px solid ${selectedSlot === slot ? "var(--color-primary)" : "#d1d5db"}`,
background: selectedSlot === slot ? "var(--color-primary)" : "#fff",
color: selectedSlot === slot ? "#fff" : "#374151",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
}}
>
{fmtTime(slot)}
</button>
))}
</div>
)}
</div>
<div style={{ display: "flex", gap: "0.75rem" }}>
<button style={secondaryBtn} onClick={() => setStep(1)}>Back</button>
<button
style={{ ...primaryBtn, opacity: selectedSlot ? 1 : 0.5 }}
disabled={!selectedSlot}
onClick={goToStep3}
>
Continue
</button>
</div>
</div>
)}
{/* ── Step 3: Contact Info ── */}
{step === 3 && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Your information</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
Contact details
</legend>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div>
<label style={label}>Full name *</label>
<input
style={input}
value={form.clientName}
onChange={(e) => setForm((f) => ({ ...f, clientName: e.target.value }))}
placeholder="Jane Smith"
/>
</div>
<div>
<label style={label}>Email *</label>
<input
type="email"
style={input}
value={form.clientEmail}
onChange={(e) => setForm((f) => ({ ...f, clientEmail: e.target.value }))}
placeholder="jane@example.com"
/>
</div>
<div>
<label style={label}>Phone</label>
<input
type="tel"
style={input}
value={form.clientPhone}
onChange={(e) => setForm((f) => ({ ...f, clientPhone: e.target.value }))}
placeholder="(555) 000-1234"
/>
</div>
</div>
</fieldset>
<fieldset style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: "0.75rem 1rem" }}>
<legend style={{ fontSize: 13, fontWeight: 600, color: "#374151", padding: "0 0.25rem" }}>
Pet details
</legend>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div>
<label style={label}>Pet name *</label>
<input
style={input}
value={form.petName}
onChange={(e) => setForm((f) => ({ ...f, petName: e.target.value }))}
placeholder="Buddy"
/>
</div>
<div>
<label style={label}>Species *</label>
<select
style={input}
value={form.petSpecies}
onChange={(e) => setForm((f) => ({ ...f, petSpecies: e.target.value }))}
>
<option value="">Select species</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="rabbit">Rabbit</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label style={label}>Breed</label>
<input
style={input}
value={form.petBreed}
onChange={(e) => setForm((f) => ({ ...f, petBreed: e.target.value }))}
placeholder="Golden Retriever"
/>
</div>
<div>
<label style={label}>Notes for groomer</label>
<textarea
style={{ ...input, minHeight: 64, resize: "vertical", fontFamily: "inherit" }}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="Any special requests or things we should know…"
/>
</div>
</div>
</fieldset>
</div>
{formError && (
<p style={{ color: "#ef4444", fontSize: 13, marginTop: "0.75rem" }}>{formError}</p>
)}
<div style={{ display: "flex", gap: "0.75rem", marginTop: "1.25rem" }}>
<button style={secondaryBtn} onClick={() => setStep(2)}>Back</button>
<button style={primaryBtn} onClick={goToStep4}>Review booking</button>
</div>
</div>
)}
{/* ── Step 4: Confirm ── */}
{step === 4 && selectedService && selectedSlot && (
<div>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: "1rem" }}>Confirm your booking</h2>
<div style={{ ...card, cursor: "default", marginBottom: "1.25rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem", fontSize: 14 }}>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
<div style={{ fontWeight: 600 }}>{fmtDateLong(date)}</div>
<div style={{ color: "#6b7280" }}>{fmtTime(selectedSlot)}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Client</div>
<div style={{ fontWeight: 600 }}>{form.clientName}</div>
<div style={{ color: "#6b7280" }}>{form.clientEmail}</div>
{form.clientPhone && <div style={{ color: "#6b7280" }}>{form.clientPhone}</div>}
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Pet</div>
<div style={{ fontWeight: 600 }}>{form.petName}</div>
<div style={{ color: "#6b7280", textTransform: "capitalize" }}>{form.petSpecies}{form.petBreed ? ` · ${form.petBreed}` : ""}</div>
</div>
{form.notes && (
<div style={{ gridColumn: "1 / -1" }}>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Notes</div>
<div style={{ color: "#374151" }}>{form.notes}</div>
</div>
)}
</div>
</div>
{submitError && (
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: "0.75rem" }}>{submitError}</p>
)}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button style={secondaryBtn} onClick={() => setStep(3)} disabled={submitting}>Back</button>
<button
style={{ ...primaryBtn, opacity: submitting ? 0.7 : 1 }}
onClick={submitBooking}
disabled={submitting}
>
{submitting ? "Booking…" : "Confirm booking"}
</button>
</div>
</div>
)}
{/* ── Step 5: Success ── */}
{step === 5 && result && (
<div style={{ textAlign: "center", padding: "2rem 1rem" }}>
<div style={{ fontSize: 48, marginBottom: "0.75rem" }}>🐾</div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#1f2937", marginBottom: "0.5rem" }}>
Booking confirmed!
</h2>
<p style={{ color: "#6b7280", fontSize: 14, marginBottom: "1.5rem" }}>
We&apos;ve booked {result.pet.name} in for{" "}
{selectedService?.name} on {fmtDateLong(date)} at{" "}
{fmtTime(result.appointment.startTime)}.
</p>
<div style={{ ...card, cursor: "default", textAlign: "left", marginBottom: "1.5rem" }}>
<p style={{ margin: 0, fontSize: 14, color: "#374151" }}>
A confirmation will be sent to <strong>{result.client.email}</strong>.
If you need to reschedule or cancel, please contact us.
</p>
</div>
<button
style={primaryBtn}
onClick={() => {
setStep(1);
setSelectedService(null);
setSelectedSlot(null);
setResult(null);
setForm({
serviceId: "", startTime: "", clientName: "", clientEmail: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
});
}}
>
Book another appointment
</button>
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
export function BookingCancelledPage() {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#fff7ed",
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
textAlign: "center",
maxWidth: 420,
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
Appointment Cancelled
</h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
Your appointment has been cancelled. If this was a mistake or you'd
like to rebook, please contact us.
</p>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "#ea580c",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
Back to Portal
</a>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
export function BookingConfirmedPage() {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#f0fdf4",
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
textAlign: "center",
maxWidth: 420,
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#15803d", fontSize: 24, margin: "0 0 0.5rem" }}>
Appointment Confirmed
</h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
Thank you! Your appointment is confirmed. We look forward to seeing you
and your furry friend.
</p>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "#16a34a",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
Back to Portal
</a>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
export function BookingErrorPage() {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#fef2f2",
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: "2.5rem 3rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
textAlign: "center",
maxWidth: 420,
}}
>
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}></div>
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
Link Invalid or Expired
</h1>
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
This confirmation link is invalid, has already been used, or your
appointment has already passed. Please contact us if you need help.
</p>
<a
href="/"
style={{
display: "inline-block",
padding: "0.6rem 1.5rem",
background: "#dc2626",
color: "#fff",
borderRadius: 6,
textDecoration: "none",
fontWeight: 600,
fontSize: 14,
}}
>
Back to Portal
</a>
</div>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useState, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
export function ClientDetailPage() {
const { clientId } = useParams<{ clientId: string }>();
const [client, setClient] = useState<Client | null>(null);
const [pets, setPets] = useState<Pet[]>([]);
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
const handlePhotoUploaded = useCallback((petId: string) => {
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
}, []);
useEffect(() => {
if (!clientId) {
setError("No client ID provided");
setLoading(false);
return;
}
async function load() {
const id = clientId!;
setLoading(true);
setError(null);
try {
const [clientRes, petsRes] = await Promise.all([
fetch(`/api/clients/${encodeURIComponent(id)}`),
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
]);
if (!clientRes.ok) {
const err = await clientRes.json().catch(() => ({})) as { error?: string };
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
}
if (!petsRes.ok) {
throw new Error(`Pets fetch failed: ${petsRes.status}`);
}
setClient(await clientRes.json() as Client);
setPets(await petsRes.json() as Pet[]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load client");
} finally {
setLoading(false);
}
}
void load();
}, [clientId]);
async function loadVisitLogs(petId: string) {
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
if (r.ok) {
const logs = await r.json() as GroomingVisitLog[];
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
}
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
}
if (loading) {
return (
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
Loading client
</div>
);
}
if (error || !client) {
return (
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<div style={{ marginBottom: "1rem" }}>
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}> Back to clients</Link>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
{error ?? "Client not found"}
</div>
</div>
);
}
return (
<div style={{ fontFamily: "system-ui, sans-serif" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
{client.status === "disabled" && (
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</div>
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
{client.notes && (
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
{client.notes}
</div>
)}
</div>
<Link
to="/admin/clients"
style={{
padding: "0.4rem 0.85rem",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "#fff",
color: "#374151",
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
flexShrink: 0,
}}
>
Back to list
</Link>
</div>
{/* Pets */}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
</div>
{pets.length === 0 ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
{/* Photo + header */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
</div>
</div>
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
)}
{/* Visit history */}
{(() => {
const logs = visitLogs[p.id];
const loadingLogs = logsLoading[p.id];
return (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
{!logs && !loadingLogs && (
<button
onClick={() => { void loadVisitLogs(p.id); }}
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
>
Load history
</button>
)}
</div>
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading</div>}
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
{logs && logs.length > 0 && (
<>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</>
)}
</div>
);
})()}
</div>
))}
</div>
)}
</div>
);
}
+930
View File
@@ -0,0 +1,930 @@
import { useEffect, useState, useCallback, useRef, useId } from "react";
import { useSearchParams } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
// ─── Forms ───────────────────────────────────────────────────────────────────
interface ClientForm {
name: string;
email: string;
phone: string;
address: string;
notes: string;
}
interface PetForm {
name: string;
species: string;
breed: string;
weightStr: string;
dob: string;
healthAlerts: string;
groomingNotes: string;
cutStyle: string;
shampooPreference: string;
specialCareNotes: string;
}
interface VisitLogForm {
cutStyle: string;
productsUsed: string;
notes: string;
groomedAt: string;
}
const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "", notes: "" };
const EMPTY_PET: PetForm = {
name: "", species: "Dog", breed: "", weightStr: "", dob: "",
healthAlerts: "", groomingNotes: "", cutStyle: "", shampooPreference: "", specialCareNotes: "",
};
const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "", groomedAt: "" };
// ─── Component ───────────────────────────────────────────────────────────────
export function ClientsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [pets, setPets] = useState<Pet[]>([]);
const [petsLoading, setPetsLoading] = useState(false);
const clientRowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Client form
const [showClientForm, setShowClientForm] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null);
const [clientForm, setClientForm] = useState<ClientForm>(EMPTY_CLIENT);
const [clientFormError, setClientFormError] = useState<string | null>(null);
const [savingClient, setSavingClient] = useState(false);
// Pet form
const [showPetForm, setShowPetForm] = useState(false);
const [editingPet, setEditingPet] = useState<Pet | null>(null);
const [petForm, setPetForm] = useState<PetForm>(EMPTY_PET);
const [petFormError, setPetFormError] = useState<string | null>(null);
const [savingPet, setSavingPet] = useState(false);
const [deletingPetId, setDeletingPetId] = useState<string | null>(null);
const [deletingClient, setDeletingClient] = useState(false);
const [disablingClient, setDisablingClient] = useState(false);
const [startingImpersonation, setStartingImpersonation] = useState(false);
const [showDisabled, setShowDisabled] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState("");
// Photo refresh counters (incremented after upload to force PetPhotoDisplay re-fetch)
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
const handlePhotoUploaded = useCallback((petId: string) => {
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
}, []);
// Visit log
const [logPetId, setLogPetId] = useState<string | null>(null);
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
const [showLogForm, setShowLogForm] = useState(false);
const [logForm, setLogForm] = useState<VisitLogForm>(EMPTY_VISIT_LOG);
const [logFormError, setLogFormError] = useState<string | null>(null);
const [savingLog, setSavingLog] = useState(false);
async function loadClients(includeDisabled = false) {
const url = includeDisabled ? "/api/clients?includeDisabled=true" : "/api/clients";
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setClients((await r.json()) as Client[]);
}
useEffect(() => {
loadClients(showDisabled)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, [showDisabled]);
// Auto-select a client when navigated here via GlobalSearch (?highlight=<clientId>)
useEffect(() => {
const highlightId = searchParams.get("highlight");
if (!highlightId || loading || clients.length === 0) return;
const match = clients.find((c) => c.id === highlightId);
if (!match) return;
selectClient(match);
const el = clientRowRefs.current.get(highlightId);
if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" });
// Remove the param so back/refresh does not re-trigger
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("highlight");
return next;
}, { replace: true });
}, [searchParams, clients, loading]); // selectClient is stable (defined in render scope)
async function loadPets(clientId: string) {
setPetsLoading(true);
const r = await fetch(`/api/pets?clientId=${encodeURIComponent(clientId)}`);
setPets((await r.json()) as Pet[]);
setPetsLoading(false);
}
async function loadVisitLogs(petId: string) {
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
if (r.ok) {
setVisitLogs((prev) => ({ ...prev, [petId]: (r.json() as unknown as Promise<GroomingVisitLog[]>).then ? [] : [] }));
const logs = (await r.json()) as GroomingVisitLog[];
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
}
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
}
function selectClient(c: Client) {
setSelectedClient(c);
loadPets(c.id);
}
// ── Client CRUD ──
function openNewClient() {
setEditingClient(null);
setClientForm(EMPTY_CLIENT);
setClientFormError(null);
setShowClientForm(true);
}
function openEditClient(c: Client) {
setEditingClient(c);
setClientForm({ name: c.name, email: c.email ?? "", phone: c.phone ?? "", address: c.address ?? "", notes: c.notes ?? "" });
setClientFormError(null);
setShowClientForm(true);
}
async function submitClient(e: React.FormEvent) {
e.preventDefault();
setSavingClient(true);
setClientFormError(null);
try {
const body = {
name: clientForm.name,
email: clientForm.email || undefined,
phone: clientForm.phone || undefined,
address: clientForm.address || undefined,
notes: clientForm.notes || undefined,
};
const res = editingClient
? await fetch(`/api/clients/${editingClient.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
: await fetch("/api/clients", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const updated = (await res.json()) as Client;
setShowClientForm(false);
await loadClients(showDisabled);
if (editingClient) setSelectedClient(updated);
} catch (e: unknown) {
setClientFormError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSavingClient(false);
}
}
// ── Pet CRUD ──
function openNewPet() {
setEditingPet(null);
setPetForm(EMPTY_PET);
setPetFormError(null);
setShowPetForm(true);
}
function openEditPet(p: Pet) {
setEditingPet(p);
setPetForm({
name: p.name, species: p.species, breed: p.breed ?? "",
weightStr: p.weightKg != null ? String(p.weightKg) : "",
dob: p.dateOfBirth ? p.dateOfBirth.slice(0, 10) : "",
healthAlerts: p.healthAlerts ?? "",
groomingNotes: p.groomingNotes ?? "",
cutStyle: p.cutStyle ?? "",
shampooPreference: p.shampooPreference ?? "",
specialCareNotes: p.specialCareNotes ?? "",
});
setPetFormError(null);
setShowPetForm(true);
}
async function deletePet(petId: string) {
if (!selectedClient) return;
if (!window.confirm("Delete this pet? This cannot be undone.")) return;
setDeletingPetId(petId);
try {
const res = await fetch(`/api/pets/${petId}`, { method: "DELETE" });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
await loadPets(selectedClient.id);
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to delete pet");
} finally {
setDeletingPetId(null);
}
}
async function disableClient(clientId: string) {
if (!window.confirm("Disable this client? They will be hidden from the client list and booking flow.")) return;
setDisablingClient(true);
try {
const res = await fetch(`/api/clients/${clientId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "disabled" }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const updated = (await res.json()) as Client;
setSelectedClient(updated);
await loadClients(showDisabled);
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to disable client");
} finally {
setDisablingClient(false);
}
}
async function enableClient(clientId: string) {
setDisablingClient(true);
try {
const res = await fetch(`/api/clients/${clientId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "active" }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const updated = (await res.json()) as Client;
setSelectedClient(updated);
await loadClients(showDisabled);
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to re-enable client");
} finally {
setDisablingClient(false);
}
}
async function deleteClient(clientId: string) {
setDeletingClient(true);
try {
const res = await fetch(`/api/clients/${clientId}?confirm=true`, { method: "DELETE" });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setSelectedClient(null);
setShowDeleteConfirm(false);
setDeleteConfirmName("");
setPets([]);
await loadClients(showDisabled);
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Failed to delete client");
} finally {
setDeletingClient(false);
}
}
async function submitPet(e: React.FormEvent) {
e.preventDefault();
if (!selectedClient) return;
setSavingPet(true);
setPetFormError(null);
try {
const body = {
clientId: selectedClient.id,
name: petForm.name,
species: petForm.species,
breed: petForm.breed || undefined,
weightKg: petForm.weightStr ? parseFloat(petForm.weightStr) : undefined,
dateOfBirth: petForm.dob ? new Date(petForm.dob).toISOString() : undefined,
healthAlerts: petForm.healthAlerts || undefined,
groomingNotes: petForm.groomingNotes || undefined,
cutStyle: petForm.cutStyle || undefined,
shampooPreference: petForm.shampooPreference || undefined,
specialCareNotes: petForm.specialCareNotes || undefined,
};
const res = editingPet
? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
: await fetch("/api/pets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowPetForm(false);
await loadPets(selectedClient.id);
} catch (e: unknown) {
setPetFormError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSavingPet(false);
}
}
// ── Visit Log ──
function openLogForm(petId: string) {
setLogPetId(petId);
setLogForm({ ...EMPTY_VISIT_LOG, groomedAt: new Date().toISOString().slice(0, 16) });
setLogFormError(null);
setShowLogForm(true);
// Load existing logs for this pet
if (!visitLogs[petId]) {
void loadVisitLogs(petId);
}
}
async function submitVisitLog(e: React.FormEvent) {
e.preventDefault();
if (!logPetId) return;
setSavingLog(true);
setLogFormError(null);
try {
const body = {
petId: logPetId,
cutStyle: logForm.cutStyle || undefined,
productsUsed: logForm.productsUsed || undefined,
notes: logForm.notes || undefined,
groomedAt: logForm.groomedAt ? new Date(logForm.groomedAt).toISOString() : undefined,
};
const res = await fetch("/api/grooming-logs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowLogForm(false);
await loadVisitLogs(logPetId);
} catch (e: unknown) {
setLogFormError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSavingLog(false);
}
}
const filtered = search
? clients.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase()) ||
c.phone?.includes(search)
)
: clients;
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", display: "flex", gap: "1.5rem" }}>
{/* ── Client list ── */}
<div style={{ width: 280, flexShrink: 0, borderRight: "1px solid #e2e8f0", paddingRight: "1rem" }}>
<div style={{ display: "flex", alignItems: "center", marginBottom: "0.75rem" }}>
<h1 style={{ margin: 0, fontSize: 20 }}>Clients</h1>
<button
onClick={openNewClient}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
>
+ New
</button>
</div>
<input
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ ...inputStyle, marginBottom: "0.5rem" }}
/>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem", fontSize: 12, color: "#6b7280", marginBottom: "0.75rem", cursor: "pointer" }}>
<input
type="checkbox"
checked={showDisabled}
onChange={(e) => setShowDisabled(e.target.checked)}
/>
Show disabled clients
</label>
{filtered.length === 0 && <p style={{ color: "#6b7280", fontSize: 14 }}>No clients found.</p>}
{filtered.map((c) => (
<div
key={c.id}
ref={(el) => {
if (el) clientRowRefs.current.set(c.id, el);
else clientRowRefs.current.delete(c.id);
}}
onClick={() => selectClient(c)}
style={{
padding: "0.5rem 0.6rem", borderRadius: 6, cursor: "pointer", marginBottom: "0.2rem",
background: selectedClient?.id === c.id ? "#eff6ff" : "transparent",
border: selectedClient?.id === c.id ? "1px solid #bfdbfe" : "1px solid transparent",
}}
>
<div style={{ fontWeight: 600, fontSize: 14, display: "flex", alignItems: "center", gap: "0.4rem" }}>
{c.name}
{c.status === "disabled" && (
<span style={{ fontSize: 10, background: "#fef2f2", color: "#dc2626", padding: "0.1rem 0.4rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</div>
{c.email && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.email}</div>}
{c.phone && <div style={{ fontSize: 12, color: "#6b7280" }}>{c.phone}</div>}
</div>
))}
</div>
{/* ── Client detail ── */}
{selectedClient ? (
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1rem" }}>
<div>
<h2 style={{ margin: "0 0 0.2rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
{selectedClient.name}
{selectedClient.status === "disabled" && (
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
Disabled
</span>
)}
</h2>
{selectedClient.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.email}</div>}
{selectedClient.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{selectedClient.phone}</div>}
{selectedClient.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{selectedClient.address}</div>}
{selectedClient.notes && (
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
{selectedClient.notes}
</div>
)}
</div>
<div style={{ display: "flex", gap: "0.5rem", marginLeft: "auto" }}>
<button
disabled={startingImpersonation}
onClick={async () => {
if (!selectedClient) return;
setStartingImpersonation(true);
try {
const res = await fetch("/api/impersonation/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: selectedClient.id,
reason: `Support view for ${selectedClient.name}`,
}),
});
if (res.ok) {
const session = await res.json() as { id: string };
window.location.href = `/?sessionId=${encodeURIComponent(session.id)}`;
} else {
const err = await res.json() as { error?: string; sessionId?: string };
if (res.status === 409 && err.sessionId) {
// Already have an active session — navigate to it
window.location.href = `/?sessionId=${encodeURIComponent(err.sessionId)}`;
} else {
alert(`Could not start impersonation: ${err.error ?? res.statusText}`);
}
}
} finally {
setStartingImpersonation(false);
}
}}
style={{ ...btnStyle, backgroundColor: "#fef3c7", color: "#92400e", borderColor: "#fde68a", display: "inline-flex", alignItems: "center", gap: "0.3rem", opacity: startingImpersonation ? 0.6 : 1, cursor: startingImpersonation ? "not-allowed" : "pointer" }}
>
{startingImpersonation ? "Starting…" : "View as Customer"}
</button>
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
Edit client
</button>
{selectedClient.status === "active" ? (
<button
onClick={() => { void disableClient(selectedClient.id); }}
disabled={disablingClient}
style={{ ...btnStyle, color: "#d97706", borderColor: "#fde68a" }}
>
{disablingClient ? "Disabling…" : "Disable client"}
</button>
) : (
<button
onClick={() => { void enableClient(selectedClient.id); }}
disabled={disablingClient}
style={{ ...btnStyle, color: "#059669", borderColor: "#6ee7b7" }}
>
{disablingClient ? "Enabling…" : "Re-enable client"}
</button>
)}
<button
onClick={() => { setShowDeleteConfirm(true); setDeleteConfirmName(""); }}
style={{ ...btnStyle, color: "#dc2626", borderColor: "#fca5a5" }}
>
Delete permanently
</button>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
<h3 style={{ margin: 0 }}>Pets</h3>
<button onClick={openNewPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
+ Add pet
</button>
</div>
{petsLoading ? (
<p style={{ fontSize: 14 }}>Loading pets</p>
) : pets.length === 0 ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
{pets.map((p) => (
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
{/* ── Photo + header ── */}
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
<PetPhotoDisplay
petId={p.id}
size={56}
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<strong style={{ fontSize: 15 }}>{p.name}</strong>
<div style={{ display: "flex", gap: "0.3rem" }}>
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
<button
onClick={() => openLogForm(p.id)}
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, backgroundColor: "#eff6ff", borderColor: "#bfdbfe" }}
>
Log visit
</button>
<button
onClick={() => { void deletePet(p.id); }}
disabled={deletingPetId === p.id}
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, color: "#dc2626", borderColor: "#fca5a5" }}
>
{deletingPetId === p.id ? "…" : "Delete"}
</button>
</div>
</div>
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
{p.species}{p.breed ? ` · ${p.breed}` : ""}
</div>
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
<div style={{ marginTop: "0.3rem" }}>
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
</div>
</div>
</div>
{p.healthAlerts && (
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
<span style={{ fontWeight: 600 }}> Health alerts:</span> {p.healthAlerts}
</div>
)}
{/* Grooming preferences */}
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
{p.cutStyle && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
</div>
)}
{p.shampooPreference && (
<div style={{ fontSize: 12, color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
</div>
)}
{p.specialCareNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
</div>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
</div>
)}
</div>
)}
{/* Visit history (loaded on demand) */}
{(() => {
const logs = visitLogs[p.id];
if (!logs || logs.length === 0) return null;
return (
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280", marginBottom: "0.25rem" }}>VISIT HISTORY</div>
{logs.slice(0, 3).map((log) => (
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.notes && <span> · {log.notes}</span>}
</div>
))}
{logs.length > 3 && (
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
)}
</div>
);
})()}
</div>
))}
</div>
)}
</div>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#9ca3af", fontSize: 15 }}>
Select a client to view details
</div>
)}
{/* ── Client modal ── */}
{showClientForm && (
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
<form onSubmit={submitClient}>
<Field label="Full name">
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
</Field>
<Field label="Email">
<input type="email" value={clientForm.email} onChange={(e) => setClientForm((f) => ({ ...f, email: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Phone">
<input value={clientForm.phone} onChange={(e) => setClientForm((f) => ({ ...f, phone: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Address">
<input value={clientForm.address} onChange={(e) => setClientForm((f) => ({ ...f, address: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Notes">
<textarea value={clientForm.notes} onChange={(e) => setClientForm((f) => ({ ...f, notes: e.target.value }))} rows={3} style={{ ...inputStyle, resize: "vertical" }} />
</Field>
{clientFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{clientFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{savingClient ? "Saving…" : editingClient ? "Save Changes" : "Create Client"}
</button>
<button type="button" onClick={() => setShowClientForm(false)} style={btnStyle}>Cancel</button>
</div>
</form>
</Modal>
)}
{/* ── Pet modal ── */}
{showPetForm && (
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
<form onSubmit={submitPet}>
<Field label="Pet name">
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
</Field>
<Field label="Species">
<select value={petForm.species} onChange={(e) => setPetForm((f) => ({ ...f, species: e.target.value }))} style={inputStyle}>
{["Dog", "Cat", "Rabbit", "Guinea Pig", "Other"].map((s) => <option key={s}>{s}</option>)}
</select>
</Field>
<Field label="Breed (optional)">
<input value={petForm.breed} onChange={(e) => setPetForm((f) => ({ ...f, breed: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Weight kg (optional)">
<input type="number" step="0.1" min="0" value={petForm.weightStr} onChange={(e) => setPetForm((f) => ({ ...f, weightStr: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Date of birth (optional)">
<input type="date" value={petForm.dob} onChange={(e) => setPetForm((f) => ({ ...f, dob: e.target.value }))} style={inputStyle} />
</Field>
<Field label="Health alerts (allergies, conditions, medications)">
<textarea
value={petForm.healthAlerts}
onChange={(e) => setPetForm((f) => ({ ...f, healthAlerts: e.target.value }))}
rows={2}
style={{ ...inputStyle, resize: "vertical", borderColor: petForm.healthAlerts ? "#fca5a5" : undefined }}
placeholder="e.g. Allergic to lavender, heart condition, on medication X"
/>
</Field>
<div style={{ borderTop: "1px solid #e5e7eb", marginTop: "0.75rem", paddingTop: "0.75rem" }}>
<div style={{ fontSize: 12, fontWeight: 700, color: "#6b7280", marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Grooming Preferences
</div>
<Field label="Preferred cut style (optional)">
<input
value={petForm.cutStyle}
onChange={(e) => setPetForm((f) => ({ ...f, cutStyle: e.target.value }))}
style={inputStyle}
placeholder="e.g. Puppy cut, Breed standard, Teddy bear"
/>
</Field>
<Field label="Shampoo / product preference (optional)">
<input
value={petForm.shampooPreference}
onChange={(e) => setPetForm((f) => ({ ...f, shampooPreference: e.target.value }))}
style={inputStyle}
placeholder="e.g. Hypoallergenic, Oatmeal, Whitening"
/>
</Field>
<Field label="Special care instructions (optional)">
<textarea
value={petForm.specialCareNotes}
onChange={(e) => setPetForm((f) => ({ ...f, specialCareNotes: e.target.value }))}
rows={2}
style={{ ...inputStyle, resize: "vertical" }}
placeholder="e.g. Needs a pee pad in pen, anxious around dryers, requires muzzle"
/>
</Field>
<Field label="General grooming notes (optional)">
<textarea value={petForm.groomingNotes} onChange={(e) => setPetForm((f) => ({ ...f, groomingNotes: e.target.value }))} rows={2} style={{ ...inputStyle, resize: "vertical" }} />
</Field>
</div>
{petFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{petFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
{savingPet ? "Saving…" : editingPet ? "Save Changes" : "Add Pet"}
</button>
<button type="button" onClick={() => setShowPetForm(false)} style={btnStyle}>Cancel</button>
</div>
</form>
</Modal>
)}
{/* ── Visit log modal ── */}
{showLogForm && logPetId && (
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history</p>}
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
<div style={{ marginBottom: "1rem" }}>
<div style={{ fontSize: 12, fontWeight: 700, color: "#6b7280", marginBottom: "0.4rem", textTransform: "uppercase" }}>Past Visits</div>
{visitLogs[logPetId].slice(0, 5).map((log) => (
<div key={log.id} style={{ fontSize: 12, borderLeft: "2px solid #e2e8f0", paddingLeft: "0.5rem", marginBottom: "0.3rem", color: "#374151" }}>
<strong>{new Date(log.groomedAt).toLocaleDateString()}</strong>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.productsUsed && <span> · {log.productsUsed}</span>}
{log.notes && <div style={{ color: "#6b7280" }}>{log.notes}</div>}
</div>
))}
</div>
)}
<form onSubmit={submitVisitLog}>
<Field label="Date &amp; time">
<input
type="datetime-local"
value={logForm.groomedAt}
onChange={(e) => setLogForm((f) => ({ ...f, groomedAt: e.target.value }))}
style={inputStyle}
required
/>
</Field>
<Field label="Cut style (optional)">
<input
value={logForm.cutStyle}
onChange={(e) => setLogForm((f) => ({ ...f, cutStyle: e.target.value }))}
style={inputStyle}
placeholder="e.g. Puppy cut, Kennel cut"
/>
</Field>
<Field label="Products used (optional)">
<input
value={logForm.productsUsed}
onChange={(e) => setLogForm((f) => ({ ...f, productsUsed: e.target.value }))}
style={inputStyle}
placeholder="e.g. Oatmeal shampoo, leave-in conditioner"
/>
</Field>
<Field label="Notes (optional)">
<textarea
value={logForm.notes}
onChange={(e) => setLogForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
style={{ ...inputStyle, resize: "vertical" }}
placeholder="Anything notable about this visit"
/>
</Field>
{logFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{logFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{savingLog ? "Saving…" : "Save Visit Log"}
</button>
<button type="button" onClick={() => setShowLogForm(false)} style={btnStyle}>Cancel</button>
</div>
</form>
</Modal>
)}
{/* ── Delete confirmation modal ── */}
{showDeleteConfirm && selectedClient && (
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
<p style={{ fontSize: 14, color: "#374151" }}>
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
</p>
<p style={{ fontSize: 14, color: "#374151" }}>
Consider disabling the client instead, which preserves their data for reporting.
</p>
<Field label={`Type "${selectedClient.name}" to confirm`}>
<input
value={deleteConfirmName}
onChange={(e) => setDeleteConfirmName(e.target.value)}
style={inputStyle}
placeholder={selectedClient.name}
/>
</Field>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button
onClick={() => { void deleteClient(selectedClient.id); }}
disabled={deletingClient || deleteConfirmName !== selectedClient.name}
style={{
...btnStyle,
backgroundColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#f3f4f6",
color: deleteConfirmName === selectedClient.name ? "#fff" : "#9ca3af",
borderColor: deleteConfirmName === selectedClient.name ? "#dc2626" : "#d1d5db",
}}
>
{deletingClient ? "Deleting…" : "Delete permanently"}
</button>
<button type="button" onClick={() => setShowDeleteConfirm(false)} style={btnStyle}>Cancel</button>
</div>
</Modal>
)}
</div>
);
}
// ─── Shared UI ───────────────────────────────────────────────────────────────
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
const titleId = useId();
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
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}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
>
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
{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",
};
+170
View File
@@ -0,0 +1,170 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
interface StaffUser {
id: string;
userId: string | null;
name: string;
email: string;
role: string;
}
interface ClientUser {
id: string;
name: string;
email: string | null;
petCount: number;
}
export function DevLoginSelector() {
const navigate = useNavigate();
const [staff, setStaff] = useState<StaffUser[]>([]);
const [clients, setClients] = useState<ClientUser[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/dev/users")
.then((r) => r.json())
.then((data) => {
setStaff(data.staff ?? []);
setClients(data.clients ?? []);
})
.finally(() => setLoading(false));
}, []);
function selectUser(type: "staff" | "client", id: string, name: string) {
localStorage.setItem("dev-user", JSON.stringify({ type, id, name }));
navigate(type === "staff" ? "/admin" : "/");
}
function skipLogin() {
localStorage.removeItem("dev-user");
navigate("/admin");
}
if (loading) {
return (
<div style={containerStyle}>
<p style={{ color: "#6b7280" }}>Loading users...</p>
</div>
);
}
return (
<div style={containerStyle}>
<div style={cardStyle}>
<div style={{ textAlign: "center", marginBottom: "1.5rem" }}>
<h1 style={{ margin: 0, fontSize: 22, color: "#1a202c" }}>
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
</h1>
<p style={{ margin: "0.5rem 0 0", color: "#6b7280", fontSize: 14 }}>
Dev Login Selector
</p>
</div>
<h2 style={sectionStyle}>Staff</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{staff.map((s) => (
<button
key={s.id}
onClick={() => selectUser("staff", s.userId ?? s.id, s.name)}
style={userButtonStyle}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
{s.role} &middot; {s.email}
</div>
</button>
))}
</div>
<h2 style={{ ...sectionStyle, marginTop: "1.5rem" }}>Clients</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{clients.map((cl) => (
<button
key={cl.id}
onClick={() => selectUser("client", cl.id, cl.name)}
style={userButtonStyle}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{cl.name}</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
{cl.petCount} pet{cl.petCount !== 1 ? "s" : ""}
{cl.email ? ` \u00b7 ${cl.email}` : ""}
</div>
</button>
))}
</div>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<button onClick={skipLogin} style={skipButtonStyle}>
Continue as default dev user
</button>
</div>
</div>
</div>
);
}
export function getDevUser(): { type: string; id: string; name: string } | null {
try {
const raw = localStorage.getItem("dev-user");
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
export function clearDevUser() {
localStorage.removeItem("dev-user");
}
const containerStyle: React.CSSProperties = {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
background: "#f0f2f5",
padding: "1rem",
};
const cardStyle: React.CSSProperties = {
background: "#fff",
borderRadius: 12,
padding: "2rem",
width: "100%",
maxWidth: 420,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)",
};
const sectionStyle: React.CSSProperties = {
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
margin: "0 0 0.5rem",
};
const userButtonStyle: React.CSSProperties = {
display: "block",
width: "100%",
padding: "0.75rem 1rem",
border: "1px solid #e5e7eb",
borderRadius: 8,
background: "#fff",
cursor: "pointer",
textAlign: "left",
transition: "border-color 0.15s, background 0.15s",
};
const skipButtonStyle: React.CSSProperties = {
padding: "0.5rem 1.25rem",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "transparent",
cursor: "pointer",
fontSize: 13,
color: "#6b7280",
};
+583
View File
@@ -0,0 +1,583 @@
import { useEffect, useState } from "react";
import type { Client, Pet, Service, Staff } from "@groombook/types";
// ─── Types ────────────────────────────────────────────────────────────────────
interface PetSlot {
petId: string;
serviceId: string;
staffId: string;
endTime: string; // HH:MM
}
interface GroupAppointment {
id: string;
petId: string;
petName?: string;
serviceId: string;
serviceName?: string;
staffId: string | null;
staffName?: string | null;
status: string;
startTime: string;
endTime: string;
}
interface AppointmentGroup {
id: string;
clientId: string;
notes: string | null;
createdAt: string;
appointments: GroupAppointment[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString();
}
const STATUS_COLORS: Record<string, string> = {
scheduled: "#3b82f6",
confirmed: "#10b981",
in_progress: "#f59e0b",
completed: "#6b7280",
cancelled: "#ef4444",
no_show: "#9ca3af",
};
// ─── New Group Booking Form ───────────────────────────────────────────────────
function NewGroupBookingForm({
clients,
pets,
services,
staff,
onCreated,
onClose,
}: {
clients: Client[];
pets: Pet[];
services: Service[];
staff: Staff[];
onCreated: () => void;
onClose: () => void;
}) {
const [clientId, setClientId] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState("09:00");
const [notes, setNotes] = useState("");
const [petSlots, setPetSlots] = useState<PetSlot[]>([
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const clientPets = pets.filter((p) => p.clientId === clientId);
const activeServices = services.filter((s) => s.active);
const activeStaff = staff.filter((s) => s.active);
function addPetSlot() {
setPetSlots((prev) => [
...prev,
{ petId: "", serviceId: "", staffId: "", endTime: "10:00" },
]);
}
function removePetSlot(i: number) {
setPetSlots((prev) => prev.filter((_, idx) => idx !== i));
}
function updateSlot(i: number, field: keyof PetSlot, value: string) {
setPetSlots((prev) =>
prev.map((slot, idx) =>
idx === i ? { ...slot, [field]: value } : slot
)
);
}
// Auto-set end time based on service duration when service changes
function handleServiceChange(i: number, serviceId: string) {
const svc = services.find((s) => s.id === serviceId);
if (svc && startTime) {
const [h, m] = startTime.split(":").map(Number);
const totalMins = (h ?? 0) * 60 + (m ?? 0) + svc.durationMinutes;
const endH = String(Math.floor(totalMins / 60) % 24).padStart(2, "0");
const endM = String(totalMins % 60).padStart(2, "0");
updateSlot(i, "serviceId", serviceId);
updateSlot(i, "endTime", `${endH}:${endM}`);
} else {
updateSlot(i, "serviceId", serviceId);
}
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!clientId) { setError("Please select a client"); return; }
if (petSlots.length < 2) { setError("Add at least 2 pets"); return; }
if (petSlots.some((s) => !s.petId || !s.serviceId)) {
setError("Each pet slot needs a pet and service selected");
return;
}
setSaving(true);
setError(null);
const payload = {
clientId,
startTime: `${date}T${startTime}:00.000Z`,
notes: notes || undefined,
pets: petSlots.map((slot) => ({
petId: slot.petId,
serviceId: slot.serviceId,
staffId: slot.staffId || undefined,
endTime: `${date}T${slot.endTime}:00.000Z`,
})),
};
try {
const res = await fetch("/api/appointment-groups", {
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}`);
}
onCreated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to create group booking");
} finally {
setSaving(false);
}
}
return (
<Modal onClose={onClose}>
<h2 style={{ marginTop: 0 }}>New Group Booking</h2>
<p style={{ fontSize: 13, color: "#6b7280", marginTop: 0 }}>
Book multiple pets from the same client in a single visit. Each pet can have a different groomer.
</p>
<form onSubmit={submit}>
<Field label="Client">
<select
value={clientId}
onChange={(e) => { setClientId(e.target.value); setPetSlots([{ petId: "", serviceId: "", staffId: "", endTime: "10:00" }, { petId: "", serviceId: "", staffId: "", endTime: "10:00" }]); }}
required
style={inputStyle}
>
<option value=""> Select client </option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</Field>
<div style={{ display: "flex", gap: "0.75rem" }}>
<Field label="Date" style={{ flex: 1 }}>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} required style={inputStyle} />
</Field>
<Field label="Start Time" style={{ flex: 1 }}>
<input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} required style={inputStyle} />
</Field>
</div>
<div style={{ marginBottom: "0.75rem" }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: "0.5rem", color: "#374151" }}>
Pets ({petSlots.length})
</div>
{petSlots.map((slot, i) => (
<div
key={i}
style={{
background: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: 6,
padding: "0.75rem",
marginBottom: "0.5rem",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>Pet {i + 1}</span>
{petSlots.length > 2 && (
<button
type="button"
onClick={() => removePetSlot(i)}
style={{ ...btnStyle, color: "#dc2626", fontSize: 12, padding: "0.2rem 0.5rem" }}
>
Remove
</button>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
<Field label="Pet">
<select
value={slot.petId}
onChange={(e) => updateSlot(i, "petId", e.target.value)}
required
style={inputStyle}
disabled={!clientId}
>
<option value=""> Select pet </option>
{clientPets.map((p) => (
<option key={p.id} value={p.id}>{p.name} ({p.species})</option>
))}
</select>
</Field>
<Field label="Service">
<select
value={slot.serviceId}
onChange={(e) => handleServiceChange(i, e.target.value)}
required
style={inputStyle}
>
<option value=""> Select service </option>
{activeServices.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</Field>
<Field label="Groomer (optional)">
<select
value={slot.staffId}
onChange={(e) => updateSlot(i, "staffId", e.target.value)}
style={inputStyle}
>
<option value=""> Unassigned </option>
{activeStaff.filter((s) => s.role === "groomer").map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</Field>
<Field label="End Time">
<input
type="time"
value={slot.endTime}
onChange={(e) => updateSlot(i, "endTime", e.target.value)}
required
style={inputStyle}
/>
</Field>
</div>
</div>
))}
<button type="button" onClick={addPetSlot} style={btnStyle}>
+ Add another pet
</button>
</div>
<Field label="Notes (optional)">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
style={{ ...inputStyle, resize: "vertical" }}
/>
</Field>
{error && <p style={{ color: "#dc2626", margin: "0.5rem 0 0", fontSize: 13 }}>{error}</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 ? "Booking…" : "Create Group Booking"}
</button>
<button type="button" onClick={onClose} style={btnStyle}>
Cancel
</button>
</div>
</form>
</Modal>
);
}
// ─── Group Card ───────────────────────────────────────────────────────────────
function GroupCard({
group,
onCancel,
}: {
group: AppointmentGroup;
onCancel: (id: string) => void;
}) {
const startTime = group.appointments[0]?.startTime;
const allCancelled = group.appointments.every((a) => a.status === "cancelled");
return (
<div
style={{
border: "1px solid #e2e8f0",
borderRadius: 8,
marginBottom: "0.75rem",
background: allCancelled ? "#f8fafc" : "#fff",
opacity: allCancelled ? 0.6 : 1,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.75rem 1rem",
borderBottom: "1px solid #e2e8f0",
background: "#f8fafc",
borderRadius: "8px 8px 0 0",
}}
>
<div>
<strong style={{ fontSize: 14 }}>
Group Visit {startTime ? fmtDate(startTime) : "—"}
{startTime && ` at ${fmtTime(startTime)}`}
</strong>
{group.notes && (
<span style={{ marginLeft: "0.75rem", fontSize: 12, color: "#6b7280" }}>
{group.notes}
</span>
)}
</div>
{!allCancelled && (
<button
onClick={() => onCancel(group.id)}
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626", fontSize: 12 }}
>
Cancel All
</button>
)}
</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ background: "#fafafa" }}>
{["Pet", "Service", "Groomer", "End Time", "Status"].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.4rem 1rem", fontWeight: 600, color: "#6b7280", fontSize: 12 }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{group.appointments.map((appt) => (
<tr key={appt.id}>
<td style={tdStyle}>{appt.petName ?? appt.petId}</td>
<td style={tdStyle}>{appt.serviceName ?? appt.serviceId}</td>
<td style={tdStyle}>{appt.staffName ?? <span style={{ color: "#9ca3af" }}>Unassigned</span>}</td>
<td style={tdStyle}>{fmtTime(appt.endTime)}</td>
<td style={tdStyle}>
<span
style={{
padding: "2px 8px",
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
background: `${STATUS_COLORS[appt.status] ?? "#6b7280"}22`,
color: STATUS_COLORS[appt.status] ?? "#374151",
}}
>
{appt.status.replace("_", " ")}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function GroupBookingPage() {
const [groups, setGroups] = useState<AppointmentGroup[]>([]);
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 [showCreate, setShowCreate] = useState(false);
const [clientFilter, setClientFilter] = useState("");
async function loadAll() {
setLoading(true);
setError(null);
try {
const qs = clientFilter ? `?clientId=${clientFilter}` : "";
const [groupRes, clientRes, petRes, svcRes, staffRes] = await Promise.all([
fetch(`/api/appointment-groups${qs}`),
fetch("/api/clients"),
fetch("/api/pets"),
fetch("/api/services"),
fetch("/api/staff"),
]);
if (!groupRes.ok || !clientRes.ok || !petRes.ok || !svcRes.ok || !staffRes.ok) {
throw new Error("Failed to load data");
}
const [groupData, clientData, petData, svcData, staffData] = await Promise.all([
groupRes.json() as Promise<AppointmentGroup[]>,
clientRes.json() as Promise<Client[]>,
petRes.json() as Promise<Pet[]>,
svcRes.json() as Promise<Service[]>,
staffRes.json() as Promise<Staff[]>,
]);
setGroups(groupData);
setClients(clientData);
setPets(petData);
setServices(svcData);
setStaff(staffData);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Unknown error");
} finally {
setLoading(false);
}
}
useEffect(() => {
loadAll();
}, [clientFilter]); // re-fetch when client filter changes
async function cancelGroup(groupId: string) {
if (!confirm("Cancel all appointments in this group visit?")) return;
const res = await fetch(`/api/appointment-groups/${groupId}`, { method: "DELETE" });
if (res.ok) loadAll();
}
if (loading && groups.length === 0) return <p style={{ padding: "1rem" }}>Loading</p>;
if (error) return <p style={{ padding: "1rem", color: "#dc2626" }}>Error: {error}</p>;
return (
<div style={{ fontFamily: "system-ui, sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1.25rem", flexWrap: "wrap" }}>
<h1 style={{ margin: 0 }}>Group Bookings</h1>
<select
value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)}
style={{ ...inputStyle, width: "auto", minWidth: 180 }}
>
<option value="">All Clients</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
+ New Group Booking
</button>
</div>
{groups.length === 0 ? (
<div style={{ textAlign: "center", padding: "3rem 1rem", color: "#6b7280" }}>
<p style={{ fontSize: 16, marginBottom: "0.5rem" }}>No group bookings yet.</p>
<p style={{ fontSize: 13 }}>
Use group bookings when a client brings multiple pets in the same visit each pet can have a different groomer working simultaneously.
</p>
</div>
) : (
groups.map((group) => (
<GroupCard
key={group.id}
group={{
...group,
appointments: group.appointments.map((appt) => ({
...appt,
petName: pets.find((p) => p.id === appt.petId)?.name,
serviceName: services.find((s) => s.id === appt.serviceId)?.name,
staffName: staff.find((s) => s.id === appt.staffId)?.name,
})),
}}
onCancel={cancelGroup}
/>
))
)}
{showCreate && (
<NewGroupBookingForm
clients={clients}
pets={pets}
services={services}
staff={staff}
onCreated={() => { setShowCreate(false); loadAll(); }}
onClose={() => setShowCreate(false)}
/>
)}
</div>
);
}
// ─── Shared UI helpers ────────────────────────────────────────────────────────
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div
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 style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 640, 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,
style,
}: {
label: string;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div style={{ marginBottom: "0.5rem", ...style }}>
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.2rem", fontSize: 12, color: "#6b7280" }}>
{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: 13,
boxSizing: "border-box",
};
const tdStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderBottom: "1px solid #f3f4f6",
color: "#374151",
};
+931
View File
@@ -0,0 +1,931 @@
import { useEffect, useState, useRef } from "react";
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
// ─── Types ────────────────────────────────────────────────────────────────────
interface InvoiceWithClient extends Invoice {
clientName?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtMoney(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
}
function fmtDate(iso: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString();
}
const STATUS_COLORS: Record<string, { bg: string; color: string }> = {
draft: { bg: "#f3f4f6", color: "#6b7280" },
pending: { bg: "#fef3c7", color: "#92400e" },
paid: { bg: "#d1fae5", color: "#065f46" },
void: { bg: "#fee2e2", color: "#991b1b" },
};
// ─── Invoice Status Badge ─────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const { bg, color } = STATUS_COLORS[status] ?? { bg: "#f3f4f6", color: "#374151" };
return (
<span
style={{
padding: "2px 8px",
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
background: bg,
color,
textTransform: "uppercase",
}}
>
{status}
</span>
);
}
// ─── Create Invoice Form ──────────────────────────────────────────────────────
interface CreateFromApptProps {
appointments: Appointment[];
clients: Client[];
services: Service[];
loading: boolean;
onOpen: () => void;
onCreated: () => void;
onClose: () => void;
}
function CreateFromAppointmentForm({
appointments,
clients,
services,
loading,
onOpen,
onCreated,
onClose,
}: CreateFromApptProps) {
const [selectedApptId, setSelectedApptId] = useState("");
useEffect(() => { onOpen(); }, [onOpen]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Only show completed appointments without an invoice already
const completedAppts = appointments.filter((a) => a.status === "completed");
function getClientName(clientId: string) {
return clients.find((c) => c.id === clientId)?.name ?? clientId;
}
function getServiceName(serviceId: string) {
return services.find((s) => s.id === serviceId)?.name ?? serviceId;
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!selectedApptId) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/invoices/from-appointment/${selectedApptId}`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
onCreated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to create invoice");
} finally {
setSaving(false);
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
return (
<Modal onClose={onClose}>
<h2 style={{ marginTop: 0 }}>Create Invoice from Appointment</h2>
<form onSubmit={submit}>
<Field label="Select Appointment">
<select
value={selectedApptId}
onChange={(e) => setSelectedApptId(e.target.value)}
required
style={inputStyle}
>
<option value=""> Choose a completed appointment </option>
{completedAppts.map((a) => (
<option key={a.id} value={a.id}>
{fmtDate(a.startTime)} · {getClientName(a.clientId)} · {getServiceName(a.serviceId)}
{a.priceCents ? ` · ${fmtMoney(a.priceCents)}` : ""}
</option>
))}
</select>
</Field>
{completedAppts.length === 0 && (
<p style={{ color: "#6b7280", fontSize: 13 }}>
No completed appointments available. Mark an appointment as completed first.
</p>
)}
{error && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button
type="submit"
disabled={saving || !selectedApptId}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving ? "Creating…" : "Create Invoice"}
</button>
<button type="button" onClick={onClose} style={btnStyle}>
Cancel
</button>
</div>
</form>
</Modal>
);
}
// ─── Invoice Detail Modal ─────────────────────────────────────────────────────
function InvoiceDetailModal({
invoice,
allStaff,
allAppointments,
loading,
onOpen,
onClose,
onUpdated,
}: {
invoice: Invoice;
allStaff: Staff[];
allAppointments: Appointment[];
loading: boolean;
onOpen: () => void;
onClose: () => void;
onUpdated: () => void;
}) {
const [saving, setSaving] = useState(false);
useEffect(() => { onOpen(); }, [onOpen]);
const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [partialAmount, setPartialAmount] = useState("");
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
// Fetch Stripe details when modal opens for paid invoices with a payment intent
useEffect(() => {
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
fetch(`/api/invoices/${invoice.id}/stripe-details`)
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setStripeDetails(data); })
.catch(() => {});
} else {
setStripeDetails(null);
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId
? allAppointments.find((a) => a.id === invoice.appointmentId)
: undefined;
function buildDefaultSplits(): Array<{ staffId: string | null; staffName: string; pct: number }> {
const groomer = linkedAppt?.staffId
? allStaff.find((s) => s.id === linkedAppt.staffId)
: undefined;
const bather = linkedAppt?.batherStaffId
? allStaff.find((s) => s.id === linkedAppt.batherStaffId)
: undefined;
if (!groomer) return [];
if (bather) {
return [
{ staffId: groomer.id, staffName: groomer.name, pct: 70 },
{ staffId: bather.id, staffName: bather.name, pct: 30 },
];
}
return [{ staffId: groomer.id, staffName: groomer.name, pct: 100 }];
}
const existingSplits = (invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => ({
staffId: s.staffId,
staffName: s.staffName,
pct: parseFloat(s.sharePct),
}));
const [tipSplits, setTipSplits] = useState<Array<{ staffId: string | null; staffName: string; pct: number }>>(
existingSplits.length > 0 ? existingSplits : buildDefaultSplits()
);
const [showSplits, setShowSplits] = useState(tipSplits.length > 0);
async function markPaid() {
setSaving(true);
setError(null);
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
// Real-time validation: prevent submit if tip splits don't sum to 100%
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
if (Math.abs(totalPct - 100) >= 0.01) {
setError("Tip split percentages must sum to 100%");
setSaving(false);
return;
}
}
try {
const patchBody: {
status: string;
paymentMethod: string;
tipCents: number;
tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>;
} = { status: "paid", paymentMethod, tipCents };
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
patchBody.tipSplits = tipSplits.map((r) => ({
staffId: r.staffId,
staffName: r.staffName,
sharePct: r.pct,
}));
}
const res = await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchBody),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
onUpdated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to update");
} finally {
setSaving(false);
}
}
async function voidInvoice() {
if (!confirm("Void this invoice? This cannot be undone.")) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "void" }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
onUpdated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to void");
} finally {
setSaving(false);
}
}
async function issueRefund() {
const amountCents = refundType === "partial"
? Math.round(parseFloat(partialAmount) * 100)
: undefined;
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
setError("Enter a valid refund amount");
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(amountCents ? { amountCents } : {}),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowRefundDialog(false);
onUpdated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to issue refund");
} finally {
setSaving(false);
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc;
return (
<Modal onClose={onClose}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}>
<h2 style={{ margin: 0 }}>Invoice</h2>
<StatusBadge status={invoice.status} />
</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14, marginBottom: "1rem" }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Description", "Qty", "Unit Price", "Total"].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.4rem 0.5rem", borderBottom: "1px solid #e2e8f0" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(invoice.lineItems ?? []).map((item) => (
<tr key={item.id}>
<td style={tdStyle}>{item.description}</td>
<td style={tdStyle}>{item.quantity}</td>
<td style={tdStyle}>{fmtMoney(item.unitPriceCents)}</td>
<td style={tdStyle}>{fmtMoney(item.totalCents)}</td>
</tr>
))}
</tbody>
</table>
<div style={{ borderTop: "1px solid #e2e8f0", paddingTop: "0.75rem", fontSize: 14 }}>
<SummaryRow label="Subtotal" value={fmtMoney(invoice.subtotalCents)} />
<SummaryRow label="Tax" value={fmtMoney(invoice.taxCents)} />
{invoice.status !== "paid" && invoice.status !== "void" ? (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.25rem 0" }}>
<span style={{ color: "#6b7280" }}>Tip</span>
<input
type="number"
min="0"
step="0.01"
value={tipStr}
onChange={(e) => setTipStr(e.target.value)}
style={{ ...inputStyle, width: 80, textAlign: "right" }}
/>
</div>
) : (
<SummaryRow label="Tip" value={fmtMoney(invoice.tipCents)} />
)}
<SummaryRow
label="Total"
value={fmtMoney(invoice.status !== "paid" && invoice.status !== "void" ? newTotal : invoice.totalCents)}
bold
/>
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{stripeDetails && (
<>
{stripeDetails.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
)}
{stripeDetails.paymentStatus && (
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
)}
{stripeDetails.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" />
)}
</>
)}
</div>
{/* ── Tip Distribution ── */}
{invoice.status !== "void" && (
<div style={{ marginTop: "0.75rem", borderTop: "1px solid #e2e8f0", paddingTop: "0.75rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>Tip Distribution</span>
{invoice.status !== "paid" && (
<button
onClick={() => setShowSplits((v) => !v)}
style={{ ...btnStyle, fontSize: 12 }}
>
{showSplits ? "Hide" : "Set up"}
</button>
)}
</div>
{/* Show existing splits on paid invoices */}
{invoice.status === "paid" && (invoice.tipSplits ?? []).length > 0 && (
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
<tbody>
{(invoice.tipSplits ?? []).map((s: InvoiceTipSplit) => (
<tr key={s.id}>
<td style={{ padding: "2px 0", color: "#374151" }}>{s.staffName}</td>
<td style={{ padding: "2px 0", color: "#6b7280", textAlign: "right" }}>{parseFloat(s.sharePct).toFixed(0)}%</td>
<td style={{ padding: "2px 0", textAlign: "right", fontWeight: 600 }}>{fmtMoney(s.shareCents)}</td>
</tr>
))}
</tbody>
</table>
)}
{invoice.status === "paid" && (invoice.tipSplits ?? []).length === 0 && (
<p style={{ fontSize: 12, color: "#9ca3af", margin: 0 }}>No split recorded.</p>
)}
{/* Editable splits before payment */}
{invoice.status !== "paid" && showSplits && (
<div>
{tipSplits.map((row, idx) => {
const splitTipCents = Math.round((row.pct / 100) * (Math.round(parseFloat(tipStr) * 100) || 0));
return (
<div key={idx} style={{ display: "flex", alignItems: "center", gap: "0.4rem", marginBottom: "0.35rem", fontSize: 13 }}>
<input
value={row.staffName}
onChange={(e) => setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, staffName: e.target.value } : r))}
style={{ ...inputStyle, flex: 1 }}
placeholder="Name"
/>
<input
type="number"
min={0}
max={100}
step={1}
value={row.pct}
onChange={(e) => setTipSplits((prev) => prev.map((r, i) => i === idx ? { ...r, pct: Number(e.target.value) } : r))}
style={{ ...inputStyle, width: 60, textAlign: "right" }}
/>
<span style={{ color: "#6b7280" }}>%</span>
<span style={{ minWidth: 60, textAlign: "right", color: "#374151" }}>{fmtMoney(splitTipCents)}</span>
<button
onClick={() => setTipSplits((prev) => prev.filter((_, i) => i !== idx))}
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626", padding: "0.2rem 0.4rem" }}
>×</button>
</div>
);
})}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.25rem" }}>
<button
onClick={() => setTipSplits((prev) => [...prev, { staffId: null, staffName: "", pct: 0 }])}
style={{ ...btnStyle, fontSize: 12 }}
>
+ Add person
</button>
{(() => {
const total = tipSplits.reduce((s, r) => s + r.pct, 0);
const ok = Math.abs(total - 100) < 0.01;
return <span style={{ fontSize: 12, color: ok ? "#10b981" : "#ef4444" }}>Total: {total.toFixed(0)}%{ok ? " ✓" : " (must be 100%)"}</span>;
})()}
</div>
</div>
)}
</div>
)}
{invoice.status !== "paid" && invoice.status !== "void" && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
<Field label="Payment Method">
<select
value={paymentMethod}
onChange={(e) => setPaymentMethod(e.target.value)}
style={inputStyle}
>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="check">Check</option>
<option value="other">Other</option>
</select>
</Field>
{error && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button
onClick={markPaid}
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}
>
{saving ? "Saving…" : "Mark as Paid"}
</button>
<button onClick={voidInvoice} disabled={saving} style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626" }}>
Void
</button>
<button onClick={onClose} style={btnStyle}>
Close
</button>
</div>
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
<button
onClick={() => setShowRefundDialog(true)}
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
{/* Refund Dialog */}
{showRefundDialog && (
<Modal onClose={() => setShowRefundDialog(false)}>
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
</p>
<div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
<input
type="radio"
name="refundType"
value="full"
checked={refundType === "full"}
onChange={() => setRefundType("full")}
/>
Full refund
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "1rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="0.00"
value={partialAmount}
onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 120 }}
/>
</div>
)}
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button
onClick={issueRefund}
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
>
{saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button>
</div>
</Modal>
)}
</Modal>
);
}
function SummaryRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "0.25rem 0",
fontWeight: bold ? 700 : 400,
fontSize: bold ? 15 : 14,
}}
>
<span style={{ color: bold ? "#111827" : "#6b7280" }}>{label}</span>
<span>{value}</span>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
interface PaginatedResponse<T> {
data: T[];
total: number;
}
export function InvoicesPage() {
const [invoiceList, setInvoiceList] = useState<InvoiceWithClient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("");
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [createData, setCreateData] = useState<{ clients: Client[]; appointments: Appointment[]; services: Service[] } | null>(null);
const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const LIMIT = 50;
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
async function loadInvoices(newOffset: number) {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (statusFilter) params.set("status", statusFilter);
const res = await fetch(`/api/invoices?${params}`);
if (!res.ok) throw new Error("Failed to load invoices");
const page = (await res.json()) as PaginatedResponse<Invoice>;
setInvoiceList(page.data);
setTotal(page.total);
setOffset(newOffset);
}
useEffect(() => {
setLoading(true);
loadInvoices(0)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, [statusFilter]);
function loadCreateData() {
if (createData) return Promise.resolve();
setCreateLoading(true);
return Promise.all([
fetch("/api/clients"),
fetch("/api/appointments"),
fetch("/api/services?includeInactive=true"),
])
.then(([c, a, s]) => Promise.all([c.json(), a.json(), s.json()]))
.then(([clients, appointments, services]) => {
setCreateData({ clients, appointments, services });
})
.finally(() => setCreateLoading(false));
}
function loadDetailData() {
if (detailData) return Promise.resolve();
setDetailLoading(true);
return Promise.all([fetch("/api/staff"), fetch("/api/appointments")])
.then(([s, a]) => Promise.all([s.json(), a.json()]))
.then(([staff, appointments]) => {
setDetailData({ staff, appointments });
})
.finally(() => setDetailLoading(false));
}
async function openInvoiceDetail(inv: InvoiceWithClient) {
const res = await fetch(`/api/invoices/${inv.id}`);
if (!res.ok) return;
const data = (await res.json()) as Invoice;
setSelectedInvoice(data);
loadDetailData();
}
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" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<h1 style={{ margin: 0 }}>Invoices</h1>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{ ...inputStyle, width: "auto" }}
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="void">Void</option>
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
>
+ Create Invoice
</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" }}>{fmtMoney(paymentStats.revenueThisMonth)}</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" }}>{fmtMoney(paymentStats.outstanding)}</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" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
</div>
{paymentStats.methodBreakdown.length > 0 && (
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
<div style={{ fontSize: 13, color: "#64748b" }}>
{paymentStats.methodBreakdown.map((b) => (
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
))}
</div>
</div>
)}
</div>
)}
{invoiceList.length === 0 ? (
<p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment.
</p>
) : (
<>
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{invoiceList.map((inv) => (
<tr key={inv.id} style={{ opacity: inv.status === "void" ? 0.5 : 1 }}>
<td style={tdStyle}>{fmtDate(inv.createdAt)}</td>
<td style={tdStyle}>{inv.clientName ?? "—"}</td>
<td style={tdStyle}>{fmtMoney(inv.subtotalCents)}</td>
<td style={tdStyle}>{fmtMoney(inv.taxCents)}</td>
<td style={tdStyle}>{fmtMoney(inv.tipCents)}</td>
<td style={{ ...tdStyle, fontWeight: 600 }}>{fmtMoney(inv.totalCents)}</td>
<td style={tdStyle}>
<StatusBadge status={inv.status} />
</td>
<td style={tdStyle}>
<button onClick={() => openInvoiceDetail(inv)} style={btnStyle}>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "0.75rem" }}>
<span style={{ fontSize: 13, color: "#6b7280" }}>
{offset + 1}{Math.min(offset + LIMIT, total)} of {total} invoices
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => { setLoading(true); loadInvoices(Math.max(0, offset - LIMIT)).finally(() => setLoading(false)); }}
disabled={offset === 0 || loading}
style={btnStyle}
>
Previous
</button>
<button
onClick={() => { setLoading(true); loadInvoices(offset + LIMIT).finally(() => setLoading(false)); }}
disabled={offset + LIMIT >= total || loading}
style={btnStyle}
>
Next
</button>
</div>
</div>
</>
)}
{showCreate && (
<CreateFromAppointmentForm
appointments={createData?.appointments ?? []}
clients={createData?.clients ?? []}
services={createData?.services ?? []}
loading={createLoading}
onOpen={() => loadCreateData()}
onCreated={() => {
setShowCreate(false);
setCreateData(null);
loadInvoices(0).catch(() => {});
}}
onClose={() => setShowCreate(false)}
/>
)}
{selectedInvoice && (
<InvoiceDetailModal
invoice={selectedInvoice}
allStaff={detailData?.staff ?? []}
allAppointments={detailData?.appointments ?? []}
loading={detailLoading}
onOpen={() => loadDetailData()}
onClose={() => {
setSelectedInvoice(null);
setDetailData(null);
}}
onUpdated={() => {
setSelectedInvoice(null);
setDetailData(null);
loadInvoices(offset).catch(() => {});
}}
/>
)}
</div>
);
}
// ─── Shared UI helpers ────────────────────────────────────────────────────────
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: 520, 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",
};
const tdStyle: React.CSSProperties = {
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
};
+411
View File
@@ -0,0 +1,411 @@
import { useEffect, useState } from "react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Summary {
from: string;
to: string;
revenue: { totalCents: number; paidInvoices: number };
appointments: { total: number; completed: number; cancelled: number; noShow: number };
clients: { total: number; new: number };
}
interface RevenuePeriod {
period: string;
totalCents: number;
invoiceCount: number;
}
interface RevenueByGroomer {
staffId: string;
staffName: string;
totalCents: number;
invoiceCount: number;
}
interface RevenueReport {
byPeriod: RevenuePeriod[];
byGroomer: RevenueByGroomer[];
}
interface ApptPeriod {
period: string;
total: number;
completed: number;
cancelled: number;
noShow: number;
}
interface ServiceRow {
serviceId: string;
serviceName: string;
appointmentCount: number;
completedCount: number;
revenueCents: number;
}
interface ChurnClient {
clientId: string;
clientName: string;
lastAppointmentAt: string | null;
}
interface ClientReport {
newClients: { clientId: string; clientName: string; createdAt: string }[];
activeInPeriodCount: number;
churnRisk: ChurnClient[];
churnRiskTotal: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtMoney(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
}
function fmtDate(iso: string | null) {
if (!iso) return "Never";
return new Date(iso).toLocaleDateString();
}
function toInputDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function buildQuery(from: string, to: string, extra: Record<string, string> = {}) {
const params = new URLSearchParams({ from: `${from}T00:00:00Z`, to: `${to}T23:59:59Z`, ...extra });
return params.toString();
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "1rem 1.25rem",
flex: 1,
minWidth: 140,
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
}}
>
<div style={{ fontSize: 11, color: "#6b7280", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</div>
<div style={{ fontSize: 26, fontWeight: 700, margin: "0.25rem 0", color: "#111827" }}>{value}</div>
{sub && <div style={{ fontSize: 12, color: "#9ca3af" }}>{sub}</div>}
</div>
);
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h2 style={{ fontSize: 15, fontWeight: 700, margin: "1.75rem 0 0.75rem", color: "#1a202c", borderBottom: "2px solid #e5e7eb", paddingBottom: "0.5rem" }}>
{children}
</h2>
);
}
function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
return (
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{headers.map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.5rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontWeight: 600, fontSize: 11, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} style={{ borderBottom: "1px solid #f3f4f6" }}>
{row.map((cell, j) => (
<td key={j} style={{ padding: "0.5rem 0.75rem", color: "#374151" }}>
{cell}
</td>
))}
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={headers.length} style={{ padding: "1.5rem 0.75rem", color: "#9ca3af", textAlign: "center" }}>
No data for this period.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function ReportsPage() {
const today = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
const [fromDate, setFromDate] = useState(toInputDate(thirtyDaysAgo));
const [toDate, setToDate] = useState(toInputDate(today));
const [groupBy, setGroupBy] = useState<"day" | "week" | "month">("day");
const [summary, setSummary] = useState<Summary | null>(null);
const [revenue, setRevenue] = useState<RevenueReport | null>(null);
const [apptTrends, setApptTrends] = useState<ApptPeriod[]>([]);
const [services, setServices] = useState<ServiceRow[]>([]);
const [clientReport, setClientReport] = useState<ClientReport | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function loadAll() {
setLoading(true);
setError(null);
try {
const qs = buildQuery(fromDate, toDate);
const qsGroup = buildQuery(fromDate, toDate, { groupBy });
const [summRes, revRes, apptRes, svcRes, clientRes] = await Promise.all([
fetch(`/api/reports/summary?${qs}`),
fetch(`/api/reports/revenue?${qsGroup}`),
fetch(`/api/reports/appointments?${qsGroup}`),
fetch(`/api/reports/services?${qs}`),
fetch(`/api/reports/clients?${qs}`),
]);
const failures = [
["summary", summRes],
["revenue", revRes],
["appointments", apptRes],
["services", svcRes],
["clients", clientRes],
].filter(([, r]) => !(r as Response).ok);
if (failures.length > 0) {
const details = await Promise.all(
failures.map(async ([name, r]) => {
const res = r as Response;
let body = "";
try { body = await res.text(); } catch { /* ignore */ }
return `${name} (HTTP ${res.status}${body ? `: ${body.slice(0, 120)}` : ""})`;
})
);
throw new Error(`Failed to load report data — ${details.join(", ")}`);
}
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
]);
setSummary(summData);
setRevenue(revData);
setApptTrends(apptData.byPeriod);
setServices(svcData.rows);
setClientReport(clientData);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Unknown error");
} finally {
setLoading(false);
}
}
useEffect(() => { loadAll(); }, []); // run on mount only
function exportCsv(type: "revenue" | "appointments" | "services") {
const qs = buildQuery(fromDate, toDate, { type });
window.open(`/api/reports/export.csv?${qs}`, "_blank");
}
const completionRate =
summary && summary.appointments.total > 0
? Math.round((summary.appointments.completed / summary.appointments.total) * 100)
: 0;
const noShowRate =
summary && summary.appointments.total > 0
? Math.round((summary.appointments.noShow / summary.appointments.total) * 100)
: 0;
return (
<div style={{ fontFamily: "system-ui, sans-serif", maxWidth: 900 }}>
{/* ── Controls ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1.25rem" }}>
<h1 style={{ margin: 0, fontSize: 22 }}>Reports</h1>
<label style={{ fontSize: 13, color: "#374151" }}>
From{" "}
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
style={inputStyle}
/>
</label>
<label style={{ fontSize: 13, color: "#374151" }}>
To{" "}
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
style={inputStyle}
/>
</label>
<label style={{ fontSize: 13, color: "#374151" }}>
Group by{" "}
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value as "day" | "week" | "month")}
style={inputStyle}
>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
</label>
<button onClick={loadAll} style={{ ...btnStyle, background: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{loading ? "Loading…" : "Refresh"}
</button>
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
<button onClick={() => exportCsv("revenue")} style={btnStyle}> Revenue CSV</button>
<button onClick={() => exportCsv("appointments")} style={btnStyle}> Appointments CSV</button>
<button onClick={() => exportCsv("services")} style={btnStyle}> Services CSV</button>
</div>
</div>
{error && <p style={{ color: "#dc2626", padding: "0.75rem", background: "#fef2f2", borderRadius: 6 }}>{error}</p>}
{/* ── KPI Cards ── */}
{summary && (
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1rem" }}>
<StatCard
label="Revenue"
value={fmtMoney(summary.revenue.totalCents)}
sub={`${summary.revenue.paidInvoices} paid invoices`}
/>
<StatCard
label="Appointments"
value={String(summary.appointments.total)}
sub={`${completionRate}% completion rate`}
/>
<StatCard
label="No-shows"
value={String(summary.appointments.noShow)}
sub={`${noShowRate}% of appointments`}
/>
<StatCard
label="Cancellations"
value={String(summary.appointments.cancelled)}
/>
<StatCard
label="New Clients"
value={String(summary.clients.new)}
sub={`${summary.clients.total} total`}
/>
</div>
)}
{/* ── Revenue by Period ── */}
<SectionHeader>Revenue by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)}</SectionHeader>
<Table
headers={["Period", "Invoices", "Revenue"]}
rows={(revenue?.byPeriod ?? []).map((r) => [
fmtDate(r.period),
r.invoiceCount,
fmtMoney(r.totalCents),
])}
/>
{/* ── Revenue by Groomer ── */}
<SectionHeader>Revenue by Groomer</SectionHeader>
<Table
headers={["Groomer", "Invoices", "Revenue"]}
rows={(revenue?.byGroomer ?? []).map((r) => [
r.staffName,
r.invoiceCount,
fmtMoney(r.totalCents),
])}
/>
{/* ── Appointment Trends ── */}
<SectionHeader>Appointment Trends by {groupBy.charAt(0).toUpperCase() + groupBy.slice(1)}</SectionHeader>
<Table
headers={["Period", "Total", "Completed", "Cancelled", "No-shows"]}
rows={apptTrends.map((r) => [
fmtDate(r.period),
r.total,
r.completed,
r.cancelled,
r.noShow,
])}
/>
{/* ── Service Popularity ── */}
<SectionHeader>Service Popularity</SectionHeader>
<Table
headers={["Service", "Appointments", "Completed", "Revenue"]}
rows={services.map((r) => [
r.serviceName,
r.appointmentCount,
r.completedCount,
fmtMoney(r.revenueCents),
])}
/>
{/* ── Client Retention ── */}
<SectionHeader>Client Retention</SectionHeader>
{clientReport && (
<div style={{ marginBottom: "0.75rem", display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
<StatCard label="New Clients" value={String(clientReport.newClients.length)} />
<StatCard label="Active This Period" value={String(clientReport.activeInPeriodCount)} />
<StatCard
label="Churn Risk (90+ days inactive)"
value={String(clientReport.churnRiskTotal)}
sub="Clients with no recent visit"
/>
</div>
)}
<SectionHeader>Churn Risk Clients Without a Visit in 90+ Days</SectionHeader>
<Table
headers={["Client", "Last Appointment"]}
rows={(clientReport?.churnRisk ?? []).map((r) => [
r.clientName,
fmtDate(r.lastAppointmentAt),
])}
/>
{clientReport && clientReport.churnRiskTotal > 20 && (
<p style={{ fontSize: 12, color: "#6b7280", marginTop: "0.25rem" }}>
Showing top 20 of {clientReport.churnRiskTotal} at-risk clients.
</p>
)}
</div>
);
}
// ─── Shared styles ────────────────────────────────────────────────────────────
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 = {
padding: "0.35rem 0.5rem",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
marginLeft: "0.25rem",
};
+316
View File
@@ -0,0 +1,316 @@
import { useEffect, useState } from "react";
import type { Service } from "@groombook/types";
interface ServiceForm {
name: string;
description: string;
priceStr: string;
durationMinutes: number;
active: boolean;
}
const EMPTY_FORM: ServiceForm = {
name: "",
description: "",
priceStr: "",
durationMinutes: 60,
active: true,
};
export function ServicesPage() {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Service | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<ServiceForm>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
async function load() {
const r = await fetch("/api/services?includeInactive=true");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as Service[];
setServices(data);
}
useEffect(() => {
load()
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, []);
function openNew() {
setEditing(null);
setForm(EMPTY_FORM);
setFormError(null);
setShowForm(true);
}
function openEdit(s: Service) {
setEditing(s);
setForm({
name: s.name,
description: s.description ?? "",
priceStr: (s.basePriceCents / 100).toFixed(2),
durationMinutes: s.durationMinutes,
active: s.active,
});
setFormError(null);
setShowForm(true);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
const price = parseFloat(form.priceStr);
if (isNaN(price) || price <= 0) {
setFormError("Price must be a positive number.");
return;
}
setSaving(true);
setFormError(null);
try {
const body = {
name: form.name,
description: form.description || undefined,
basePriceCents: Math.round(price * 100),
durationMinutes: form.durationMinutes,
active: form.active,
};
const res = editing
? await fetch(`/api/services/${editing.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
: await fetch("/api/services", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowForm(false);
await load();
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
}
async function toggleActive(s: Service) {
setTogglingId(s.id);
try {
await fetch(`/api/services/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: !s.active }),
});
await load();
} finally {
setTogglingId(null);
}
}
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" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<h1 style={{ margin: 0 }}>Services</h1>
<button
onClick={openNew}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
>
+ Add Service
</button>
</div>
{services.length === 0 ? (
<p>No services configured yet.</p>
) : (
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{services.map((s) => (
<tr key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
<td style={tdStyle}>{s.name}</td>
<td style={tdStyle}>{s.description ?? "—"}</td>
<td style={tdStyle}>${(s.basePriceCents / 100).toFixed(2)}</td>
<td style={tdStyle}>{s.durationMinutes} min</td>
<td style={tdStyle}>
<button
onClick={() => toggleActive(s)}
disabled={togglingId === s.id}
title={s.active ? "Deactivate" : "Activate"}
style={{
position: "relative",
width: 36,
height: 20,
borderRadius: 10,
border: "1px solid",
borderColor: s.active ? "#10b981" : "#d1d5db",
background: s.active ? "#d1fae5" : "#fff",
cursor: togglingId === s.id ? "not-allowed" : "pointer",
padding: 0,
display: "inline-flex",
alignItems: "center",
opacity: togglingId === s.id ? 0.6 : 1,
}}
>
<span style={{
position: "absolute",
left: s.active ? 17 : 2,
width: 14,
height: 14,
borderRadius: 7,
background: s.active ? "#10b981" : "#d1d5db",
transition: "left 0.15s ease",
}} />
{togglingId === s.id && (
<span style={{ position: "absolute", fontSize: 9, color: s.active ? "#065f46" : "#6b7280", fontWeight: 700 }}></span>
)}
</button>
</td>
<td style={{ ...tdStyle, whiteSpace: "nowrap" }}>
<button onClick={() => openEdit(s)} style={{ ...btnStyle, marginRight: "0.4rem" }}>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showForm && (
<Modal onClose={() => setShowForm(false)}>
<h2 style={{ marginTop: 0 }}>{editing ? "Edit Service" : "New Service"}</h2>
<form onSubmit={submit}>
<Field label="Name">
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
style={inputStyle}
/>
</Field>
<Field label="Description (optional)">
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
rows={2}
style={{ ...inputStyle, resize: "vertical" }}
/>
</Field>
<Field label="Price ($)">
<input
type="number"
step="0.01"
min="0.01"
value={form.priceStr}
onChange={(e) => setForm((f) => ({ ...f, priceStr: e.target.value }))}
required
style={inputStyle}
/>
</Field>
<Field label="Duration (minutes)">
<input
type="number"
min="5"
step="5"
value={form.durationMinutes}
onChange={(e) => setForm((f) => ({ ...f, durationMinutes: parseInt(e.target.value) || 60 }))}
required
style={inputStyle}
/>
</Field>
<Field label="Status">
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer" }}>
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
/>
Active (visible to booking form)
</label>
</Field>
{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…" : editing ? "Save Changes" : "Create Service"}
</button>
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>
Cancel
</button>
</div>
</form>
</Modal>
)}
</div>
);
}
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div
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 style={{
background: "#fff", borderRadius: 8, padding: "1.5rem",
maxWidth: 480, 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",
};
const tdStyle: React.CSSProperties = {
padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6",
};
+791
View File
@@ -0,0 +1,791 @@
import { useState, useEffect, useRef } from "react";
import { useBranding } from "../BrandingContext.js";
interface AuthProviderConfig {
id: number;
providerId: string;
displayName: string;
issuerUrl: string;
internalBaseUrl: string | null;
clientId: string;
clientSecret: string;
scopes: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
interface AuthProviderForm {
providerId: string;
displayName: string;
issuerUrl: string;
internalBaseUrl: string;
clientId: string;
clientSecret: string;
scopes: string;
}
const REDACTED = "••••••••";
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
interface CurrentUser {
id: string;
name: string;
email: string;
role: string;
isSuperUser: boolean;
}
interface SettingsForm {
businessName: string;
primaryColor: string;
accentColor: string;
logoKey: string | null;
logoUrl: string | null;
logoBase64: string | null; // legacy
logoMimeType: string | null; // legacy
}
export function SettingsPage() {
const { refresh } = useBranding();
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
// Auth provider state
const [authConfig, setAuthConfig] = useState<AuthProviderConfig | null>(null);
const [authForm, setAuthForm] = useState<AuthProviderForm>({
providerId: "authentik",
displayName: "",
issuerUrl: "",
internalBaseUrl: "",
clientId: "",
clientSecret: "",
scopes: "openid profile email",
});
const [authSecretTouched, setAuthSecretTouched] = useState(false);
const [authLoaded, setAuthLoaded] = useState(false);
const [authSaving, setAuthSaving] = useState(false);
const [authMessage, setAuthMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
const [showInternalBaseUrl, setShowInternalBaseUrl] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [form, setForm] = useState<SettingsForm>({
businessName: "",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoKey: null,
logoUrl: null,
logoBase64: null,
logoMimeType: null,
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [loaded, setLoaded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch("/api/admin/settings")
.then((r) => r.json())
.then(async (data) => {
// The logo is now proxied through the API server so the browser
// never receives an S3 URL — use the proxy path directly as the src.
setForm({
businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null,
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null,
});
setLoaded(true);
})
.catch(() => setLoaded(true));
}, []);
// Load current user (for isSuperUser check) and auth provider config
useEffect(() => {
Promise.all([
fetch("/api/staff/me").then((r) => r.json()).catch(() => null),
fetch("/api/admin/auth-provider").then(async (r) => {
if (r.ok) return r.json();
if (r.status === 404) return null;
throw new Error(`HTTP ${r.status}`);
}).catch(() => null),
]).then(([user, auth]) => {
setCurrentUser(user as CurrentUser | null);
if (auth) {
setAuthConfig(auth as AuthProviderConfig);
setAuthForm({
providerId: (auth as AuthProviderConfig).providerId,
displayName: (auth as AuthProviderConfig).displayName,
issuerUrl: (auth as AuthProviderConfig).issuerUrl,
internalBaseUrl: (auth as AuthProviderConfig).internalBaseUrl ?? "",
clientId: (auth as AuthProviderConfig).clientId,
clientSecret: (auth as AuthProviderConfig).clientSecret,
scopes: (auth as AuthProviderConfig).scopes,
});
}
setAuthLoaded(true);
});
}, []);
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 512 * 1024) {
setMessage({ type: "error", text: "Logo must be under 512KB." });
return;
}
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
return;
}
try {
// Upload directly through the API server to avoid mixed-content issues
// with pre-signed URLs that use the internal HTTP endpoint
const formData = new FormData();
formData.append("file", file);
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
method: "POST",
body: formData,
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => null);
throw new Error(err?.error ?? "Failed to upload logo");
}
const { logoKey } = await uploadRes.json();
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
setMessage({ type: "success", text: "Logo uploaded." });
refresh();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Logo upload failed" });
}
};
const handleSave = async () => {
setSaving(true);
setMessage(null);
try {
const res = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "Failed to save settings");
}
setMessage({ type: "success", text: "Settings saved." });
refresh();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" });
} finally {
setSaving(false);
}
};
// Auth provider handlers
const handleTestConnection = async () => {
setTestingConnection(true);
setTestResult(null);
try {
const res = await fetch("/api/admin/auth-provider/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
issuerUrl: authForm.issuerUrl,
...(authForm.internalBaseUrl ? { internalBaseUrl: authForm.internalBaseUrl } : {}),
}),
});
const data = await res.json();
setTestResult(data);
} catch {
setTestResult({ ok: false, error: "Network error. Please try again." });
} finally {
setTestingConnection(false);
}
};
const handleAuthSave = async () => {
setAuthSaving(true);
setAuthMessage(null);
try {
const payload: Record<string, string> = {
providerId: authForm.providerId,
displayName: authForm.displayName,
issuerUrl: authForm.issuerUrl,
clientId: authForm.clientId,
scopes: authForm.scopes,
};
if (authForm.internalBaseUrl) {
payload.internalBaseUrl = authForm.internalBaseUrl;
}
// Only send clientSecret if user changed it from the redacted value
if (authSecretTouched) {
payload.clientSecret = authForm.clientSecret;
}
const res = await fetch("/api/admin/auth-provider", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "Failed to save auth provider");
}
const saved = await res.json() as AuthProviderConfig;
setAuthConfig(saved);
setAuthForm({
providerId: saved.providerId,
displayName: saved.displayName,
issuerUrl: saved.issuerUrl,
internalBaseUrl: saved.internalBaseUrl ?? "",
clientId: saved.clientId,
clientSecret: saved.clientSecret,
scopes: saved.scopes,
});
setAuthSecretTouched(false);
setAuthMessage({ type: "success", text: "Auth provider saved." });
} catch (err: unknown) {
setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" });
} finally {
setAuthSaving(false);
}
};
const handleResetToEnvDefaults = async () => {
if (!confirmReset) {
setConfirmReset(true);
return;
}
setConfirmReset(false);
try {
const res = await fetch("/api/admin/auth-provider", { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "Failed to reset auth provider");
}
setAuthConfig(null);
setAuthForm({
providerId: "authentik",
displayName: "",
issuerUrl: "",
internalBaseUrl: "",
clientId: "",
clientSecret: "",
scopes: "openid profile email",
});
setAuthSecretTouched(false);
setAuthMessage({ type: "success", text: "Auth provider reset to environment defaults." });
} catch (err: unknown) {
setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Reset failed" });
}
};
if (!loaded) return <p>Loading settings...</p>;
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
return (
<div style={{ maxWidth: 600 }}>
<h1>Branding & Appearance</h1>
<p style={{ color: "#6b7280", marginBottom: "1.5rem" }}>
Customize your business name, logo, and color scheme.
</p>
{/* Business Name */}
<div style={{ marginBottom: "1.25rem" }}>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
Business Name
</label>
<input
type="text"
value={form.businessName}
onChange={(e) => setForm((f) => ({ ...f, businessName: e.target.value }))}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
}}
/>
</div>
{/* Logo Upload */}
<div style={{ marginBottom: "1.25rem" }}>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
Logo
</label>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
{logoSrc ? (
<img
src={logoSrc}
alt="Logo preview"
style={{ width: 64, height: 64, objectFit: "contain", borderRadius: 8, border: "1px solid #e5e7eb" }}
/>
) : (
<div style={{
width: 64, height: 64, borderRadius: 8,
border: "2px dashed #d1d5db", display: "flex",
alignItems: "center", justifyContent: "center",
color: "#9ca3af", fontSize: 12,
}}>
No logo
</div>
)}
<div>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: "0.4rem 0.75rem",
border: "1px solid #d1d5db",
borderRadius: 6,
background: "#fff",
cursor: "pointer",
fontSize: 13,
}}
>
Upload Logo
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleLogoChange}
style={{ display: "none" }}
/>
{logoSrc && (
<button
onClick={async () => {
try {
const res = await fetch("/api/admin/settings/logo", { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "Failed to delete logo");
}
setForm((f) => ({ ...f, logoKey: null, logoUrl: null, logoBase64: null, logoMimeType: null }));
setMessage({ type: "success", text: "Logo removed." });
refresh();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Delete failed" });
}
}}
style={{
marginLeft: 8,
padding: "0.4rem 0.75rem",
border: "1px solid #fca5a5",
borderRadius: 6,
background: "#fff",
color: "#dc2626",
cursor: "pointer",
fontSize: 13,
}}
>
Remove
</button>
)}
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: 4 }}>
PNG, SVG, JPEG, or WebP. Max 512KB.
</p>
</div>
</div>
</div>
{/* Color Pickers */}
<div style={{ display: "flex", gap: "1.5rem", marginBottom: "1.5rem" }}>
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
Primary Color
</label>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="color"
value={form.primaryColor}
onChange={(e) => setForm((f) => ({ ...f, primaryColor: e.target.value }))}
style={{ width: 40, height: 40, border: "none", cursor: "pointer" }}
/>
<input
type="text"
value={form.primaryColor}
onChange={(e) => setForm((f) => ({ ...f, primaryColor: e.target.value }))}
style={{
width: 90,
padding: "0.4rem 0.5rem",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
fontFamily: "monospace",
}}
/>
</div>
</div>
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4 }}>
Accent Color
</label>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="color"
value={form.accentColor}
onChange={(e) => setForm((f) => ({ ...f, accentColor: e.target.value }))}
style={{ width: 40, height: 40, border: "none", cursor: "pointer" }}
/>
<input
type="text"
value={form.accentColor}
onChange={(e) => setForm((f) => ({ ...f, accentColor: e.target.value }))}
style={{
width: 90,
padding: "0.4rem 0.5rem",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
fontFamily: "monospace",
}}
/>
</div>
</div>
</div>
{/* Preview */}
<div style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: 8,
marginBottom: "1.5rem",
background: "#fafafa",
}}>
<p style={{ fontWeight: 600, marginBottom: 8, fontSize: 13, color: "#6b7280" }}>Preview</p>
<div style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "0.5rem 1rem",
background: "#fff",
borderRadius: 6,
border: "1px solid #e5e7eb",
}}>
{logoSrc && (
<img src={logoSrc} alt="" style={{ width: 28, height: 28, objectFit: "contain" }} />
)}
<strong style={{ color: form.primaryColor }}>{form.businessName}</strong>
<span style={{
marginLeft: "auto",
padding: "0.25rem 0.75rem",
borderRadius: 4,
color: "#fff",
background: form.primaryColor,
fontSize: 13,
}}>
Button
</span>
<span style={{
padding: "0.25rem 0.75rem",
borderRadius: 4,
color: "#fff",
background: form.accentColor,
fontSize: 13,
}}>
Accent
</span>
</div>
</div>
{/* Save */}
{message && (
<div style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
marginBottom: "1rem",
fontSize: 14,
background: message.type === "success" ? "#ecfdf5" : "#fef2f2",
color: message.type === "success" ? "#065f46" : "#991b1b",
border: `1px solid ${message.type === "success" ? "#a7f3d0" : "#fecaca"}`,
}}>
{message.text}
</div>
)}
<button
onClick={handleSave}
disabled={saving || !form.businessName.trim()}
style={{
padding: "0.5rem 1.5rem",
borderRadius: 6,
border: "none",
background: form.primaryColor,
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: saving ? "wait" : "pointer",
opacity: saving ? 0.7 : 1,
}}
>
{saving ? "Saving..." : "Save Changes"}
</button>
{/* Auth Provider Section — super users only */}
{currentUser?.isSuperUser && (
<>
<hr style={{ margin: "2rem 0", border: "none", borderTop: "1px solid #e5e7eb" }} />
<h2>Authentication Provider</h2>
<p style={{ color: "#6b7280", marginBottom: "1rem" }}>
Configure the SSO provider for sign-in. Changes require a service restart.
</p>
{/* Warning banner */}
<div style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
marginBottom: "1rem",
fontSize: 13,
background: "#fef3c7",
color: "#92400e",
border: "1px solid #fde68a",
}}>
Changing auth settings will require a service restart. Active sessions will be preserved.
</div>
{/* Environment config banner */}
{!authConfig && authLoaded && (
<div style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
marginBottom: "1rem",
fontSize: 13,
background: "#eff6ff",
color: "#1e40af",
border: "1px solid #bfdbfe",
}}>
Currently using environment configuration (no DB config set).
</div>
)}
{!authLoaded && <p style={{ color: "#6b7280", fontSize: 14 }}>Loading auth provider...</p>}
{authLoaded && (
<>
<div style={{ display: "flex", flexDirection: "column", gap: "0.875rem", marginBottom: "1rem" }}>
{/* Provider ID */}
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Provider ID</label>
<input
type="text"
value={authForm.providerId}
onChange={(e) => setAuthForm((f) => ({ ...f, providerId: e.target.value }))}
placeholder="e.g. authentik, okta"
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
</div>
{/* Display Name */}
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Display Name</label>
<input
type="text"
value={authForm.displayName}
onChange={(e) => setAuthForm((f) => ({ ...f, displayName: e.target.value }))}
placeholder="e.g. Company SSO"
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
</div>
{/* Issuer URL */}
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>
Issuer URL
</label>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input
type="url"
value={authForm.issuerUrl}
onChange={(e) => setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))}
placeholder="https://your-idp.example.com"
style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
<button
onClick={handleTestConnection}
disabled={testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim()}
style={{
padding: "0.5rem 0.875rem",
borderRadius: 6,
border: "1px solid #d1d5db",
background: "#fff",
cursor: testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? "not-allowed" : "pointer",
fontSize: 13,
opacity: testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? 0.6 : 1,
whiteSpace: "nowrap",
}}
>
{testingConnection ? "Testing..." : "Test Connection"}
</button>
</div>
</div>
{/* Test result */}
{testResult && (
<div style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
fontSize: 13,
background: testResult.ok ? "#ecfdf5" : "#fef2f2",
color: testResult.ok ? "#065f46" : "#991b1b",
border: `1px solid ${testResult.ok ? "#a7f3d0" : "#fecaca"}`,
}}>
{testResult.ok ? "✓ Connection successful" : `${testResult.error}`}
</div>
)}
{/* Internal Base URL — collapsible */}
<div>
<button
onClick={() => setShowInternalBaseUrl((v) => !v)}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
fontSize: 13,
color: "#4b5563",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{showInternalBaseUrl ? "▾" : "▸"} Internal Base URL
<span style={{ fontSize: 11, color: "#9ca3af", fontWeight: 400 }}>(optional hairpin NAT)</span>
</button>
{showInternalBaseUrl && (
<input
type="url"
value={authForm.internalBaseUrl}
onChange={(e) => setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))}
placeholder="http://host.docker.internal:9080"
style={{ marginTop: 4, width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
)}
</div>
{/* Client ID */}
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Client ID</label>
<input
type="text"
value={authForm.clientId}
onChange={(e) => setAuthForm((f) => ({ ...f, clientId: e.target.value }))}
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
</div>
{/* Client Secret */}
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Client Secret</label>
<input
type="password"
value={authSecretTouched ? authForm.clientSecret : (authForm.clientSecret === REDACTED ? "" : authForm.clientSecret)}
onChange={(e) => {
setAuthSecretTouched(true);
setAuthForm((f) => ({ ...f, clientSecret: e.target.value }));
}}
placeholder={authConfig ? "(unchanged)" : "Required"}
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
{authConfig && !authSecretTouched && (
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: 2 }}>Leave blank to keep existing secret.</p>
)}
</div>
{/* Scopes */}
<div>
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Scopes</label>
<input
type="text"
value={authForm.scopes}
onChange={(e) => setAuthForm((f) => ({ ...f, scopes: e.target.value }))}
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
</div>
</div>
{/* Auth messages */}
{authMessage && (
<div style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
marginBottom: "1rem",
fontSize: 14,
background: authMessage.type === "success" ? "#ecfdf5" : "#fef2f2",
color: authMessage.type === "success" ? "#065f46" : "#991b1b",
border: `1px solid ${authMessage.type === "success" ? "#a7f3d0" : "#fecaca"}`,
}}>
{authMessage.text}
</div>
)}
{/* Action buttons */}
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
<button
onClick={handleAuthSave}
disabled={authSaving || !authForm.providerId.trim() || !authForm.issuerUrl.trim() || !authForm.clientId.trim()}
style={{
padding: "0.5rem 1.25rem",
borderRadius: 6,
border: "none",
background: "#4f8a6f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: authSaving || !authForm.providerId.trim() || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? "not-allowed" : "pointer",
opacity: authSaving ? 0.7 : 1,
}}
>
{authSaving ? "Saving..." : "Save"}
</button>
<button
onClick={handleResetToEnvDefaults}
style={{
padding: "0.5rem 1.25rem",
borderRadius: 6,
border: confirmReset ? "1px solid #dc2626" : "1px solid #d1d5db",
background: confirmReset ? "#fef2f2" : "#fff",
color: confirmReset ? "#dc2626" : "#6b7280",
fontWeight: 500,
fontSize: 14,
cursor: "pointer",
}}
>
{confirmReset ? "Confirm Reset to Env Defaults?" : "Reset to Environment Defaults"}
</button>
{confirmReset && (
<button
onClick={() => setConfirmReset(false)}
style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
border: "1px solid #d1d5db",
background: "#fff",
color: "#6b7280",
fontSize: 14,
cursor: "pointer",
}}
>
Cancel
</button>
)}
</div>
</>
)}
</>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { SetupWizard } from "./SetupWizard.tsx";
+503
View File
@@ -0,0 +1,503 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useBranding } from "../BrandingContext.js";
interface SetupStatus {
showAuthProviderStep?: boolean;
}
interface TestResult {
ok: boolean;
error?: string;
}
interface AuthFormState {
providerId: string;
displayName: string;
issuerUrl: string;
internalBaseUrl: string;
clientId: string;
clientSecret: string;
scopes: string;
}
interface Step {
id: string;
title: string;
description: string;
}
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
const navigate = useNavigate();
const { refresh: refreshBranding } = useBranding();
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
const [loadingStatus, setLoadingStatus] = useState(true);
const [authForm, setAuthForm] = useState<AuthFormState>({
providerId: "authentik",
displayName: "",
issuerUrl: "",
internalBaseUrl: "",
clientId: "",
clientSecret: "",
scopes: "openid profile email",
});
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [step, setStep] = useState(0);
const [businessName, setBusinessName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/setup/status")
.then((r) => r.json() as Promise<SetupStatus>)
.then((data) => {
setSetupStatus(data);
setLoadingStatus(false);
})
.catch(() => {
setLoadingStatus(false);
});
}, []);
const STEPS: Step[] = setupStatus?.showAuthProviderStep
? [
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
{ id: "business", title: "Business Name", description: "What is the name of your business?" },
{ id: "superuser", title: "Super User", description: "You will be designated as a Super User with full administrative access." },
{ id: "admin", title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
{ id: "done", title: "All Set!", description: "Your GroomBook instance is ready to use." },
]
: [
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
{ id: "business", title: "Business Name", description: "What is the name of your business?" },
{ id: "superuser", title: "Super User", description: "You will be designated as a Super User with full administrative access." },
{ id: "admin", title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
{ id: "done", title: "All Set!", description: "Your GroomBook instance is ready to use." },
];
const current = STEPS[step];
const isLast = step === STEPS.length - 1;
const isFirst = step === 0;
const canGoBack = step > 0 && step < STEPS.length - 1;
const canGoNext = (() => {
if (step === STEPS.length - 1) return true;
if (current?.id === "business") return businessName.trim().length > 0;
if (current?.id === "auth") {
return (
authForm.displayName.trim().length > 0 &&
authForm.issuerUrl.trim().length > 0 &&
authForm.clientId.trim().length > 0 &&
authForm.clientSecret.trim().length > 0
);
}
return true;
})();
const handleTestConnection = async () => {
setTestingConnection(true);
setTestResult(null);
try {
const res = await fetch("/api/setup/auth-provider/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: authForm.providerId,
displayName: authForm.displayName,
issuerUrl: authForm.issuerUrl,
internalBaseUrl: authForm.internalBaseUrl || null,
clientId: authForm.clientId,
scopes: authForm.scopes,
}),
});
const data = (await res.json()) as TestResult;
setTestResult(data);
} catch {
setTestResult({ ok: false, error: "Network error. Please try again." });
} finally {
setTestingConnection(false);
}
};
const handleNext = async () => {
if (step === STEPS.length - 1) {
navigate("/admin");
return;
}
if (current?.id === "auth") {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/setup/auth-provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: authForm.providerId,
displayName: authForm.displayName,
issuerUrl: authForm.issuerUrl,
internalBaseUrl: authForm.internalBaseUrl || null,
clientId: authForm.clientId,
clientSecret: authForm.clientSecret,
scopes: authForm.scopes,
}),
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
setError(data.error || "Failed to save auth provider configuration. Please try again.");
setLoading(false);
return;
}
} catch {
setError("Network error. Please try again.");
setLoading(false);
return;
}
setLoading(false);
}
if (current?.id === "business" && businessName.trim()) {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ businessName: businessName.trim() }),
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
setError(data.error || "Setup failed. Please try again.");
setLoading(false);
return;
}
refreshBranding();
if (onSetupComplete) onSetupComplete();
} catch {
setError("Network error. Please try again.");
setLoading(false);
return;
}
setLoading(false);
}
setStep((s) => s + 1);
};
const handleBack = () => {
if (step > 0) setStep((s) => s - 1);
};
if (loadingStatus) {
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f0f2f5",
fontFamily: "system-ui, sans-serif",
}}>
<p style={{ color: "#6b7280" }}>Loading...</p>
</div>
);
}
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.6rem 0.85rem",
borderRadius: 8,
border: "1px solid #d1d5db",
fontSize: 15,
outline: "none",
boxSizing: "border-box",
marginBottom: error ? "0.5rem" : 0,
};
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f0f2f5",
fontFamily: "system-ui, sans-serif",
}}>
<div style={{
background: "#fff",
borderRadius: 12,
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
padding: "2.5rem 3rem",
maxWidth: 480,
width: "100%",
}}>
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
{STEPS.map((_, i) => (
<div
key={i}
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
opacity: i === step ? 1 : i < step ? 0.5 : 1,
transition: "background 0.2s",
}}
/>
))}
</div>
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
Step {step + 1} of {STEPS.length}
</p>
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
{current?.title}
</h2>
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
{current?.description}
</p>
{current?.id === "business" && (
<input
type="text"
placeholder="e.g. Happy Paws Grooming"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
autoFocus
style={inputStyle}
/>
)}
{current?.id === "auth" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Provider ID
</label>
<input
type="text"
placeholder="e.g. authentik"
value={authForm.providerId}
onChange={(e) => setAuthForm((f) => ({ ...f, providerId: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Display Name
</label>
<input
type="text"
placeholder="e.g. Company SSO"
value={authForm.displayName}
onChange={(e) => setAuthForm((f) => ({ ...f, displayName: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Issuer URL
</label>
<input
type="url"
placeholder="https://auth.example.com"
value={authForm.issuerUrl}
onChange={(e) => setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
</label>
<input
type="url"
placeholder="https://auth.internal.example.com"
value={authForm.internalBaseUrl}
onChange={(e) => setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client ID
</label>
<input
type="text"
placeholder="Your OAuth client ID"
value={authForm.clientId}
onChange={(e) => setAuthForm((f) => ({ ...f, clientId: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client Secret
</label>
<input
type="password"
placeholder="Your OAuth client secret"
value={authForm.clientSecret}
onChange={(e) => setAuthForm((f) => ({ ...f, clientSecret: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Scopes
</label>
<input
type="text"
placeholder="openid profile email"
value={authForm.scopes}
onChange={(e) => setAuthForm((f) => ({ ...f, scopes: e.target.value }))}
style={{ ...inputStyle, fontSize: 14 }}
/>
</div>
<button
type="button"
onClick={() => { void handleTestConnection(); }}
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
style={{
padding: "0.45rem 0.85rem",
borderRadius: 6,
border: "1px solid #d1d5db",
background: "#fff",
color: "#374151",
fontSize: 13,
fontWeight: 500,
cursor: testingConnection || !authForm.issuerUrl || !authForm.clientId ? "not-allowed" : "pointer",
opacity: testingConnection || !authForm.issuerUrl || !authForm.clientId ? 0.6 : 1,
alignSelf: "flex-start",
}}
>
{testingConnection ? "Testing..." : "Test Connection"}
</button>
{testResult && (
<div style={{
padding: "0.5rem 0.75rem",
borderRadius: 6,
fontSize: 13,
background: testResult.ok ? "#ecfdf5" : "#fef2f2",
color: testResult.ok ? "#065f46" : "#991b1b",
border: `1px solid ${testResult.ok ? "#a7f3d0" : "#fecaca"}`,
}}>
{testResult.ok
? "Connection successful!"
: `Connection failed: ${testResult.error}`}
</div>
)}
</div>
)}
{current?.id === "superuser" && (
<div style={{
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 8,
padding: "0.85rem 1rem",
fontSize: 14,
color: "#166534",
marginBottom: "1rem",
}}>
As a Super User, you can manage all settings, staff, and appointments.
</div>
)}
{current?.id === "admin" && (
<div style={{
background: "#fffbeb",
border: "1px solid #fde68a",
borderRadius: 8,
padding: "0.85rem 1rem",
fontSize: 14,
color: "#92400e",
}}>
You can add additional Super Users from the Staff management page after setup.
</div>
)}
{error && (
<p style={{
margin: "0.5rem 0 0",
fontSize: 13,
color: "#dc2626",
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
padding: "0.5rem 0.75rem",
}}>
{error}
</p>
)}
<div style={{
display: "flex",
gap: "0.75rem",
marginTop: current?.id === "auth" ? "1.25rem" : current?.id === "admin" ? "1.5rem" : "1.25rem",
justifyContent: isFirst ? "flex-end" : "space-between",
}}>
{canGoBack && (
<button
onClick={handleBack}
disabled={loading}
style={{
padding: "0.55rem 1.1rem",
borderRadius: 8,
border: "1px solid #d1d5db",
background: "#fff",
color: "#374151",
fontSize: 14,
fontWeight: 500,
cursor: loading ? "not-allowed" : "pointer",
opacity: loading ? 0.6 : 1,
}}
>
Back
</button>
)}
<button
onClick={() => { void handleNext(); }}
disabled={(!canGoNext && !isLast) || loading}
style={{
padding: "0.55rem 1.25rem",
borderRadius: 8,
border: "none",
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
opacity: loading ? 0.7 : 1,
marginLeft: canGoBack ? 0 : "auto",
}}
>
{loading
? "Setting up..."
: isLast
? "Go to Dashboard"
: current?.id === "business" || current?.id === "auth"
? "Continue"
: "Next"}
</button>
</div>
</div>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { useEffect, useState } from "react";
import type { Staff } from "@groombook/types";
interface StaffForm {
name: string;
email: string;
role: "groomer" | "receptionist" | "manager";
}
const EMPTY_FORM: StaffForm = { name: "", email: "", role: "groomer" };
export function StaffPage() {
const [staff, setStaff] = useState<Staff[]>([]);
const [currentUser, setCurrentUser] = useState<Staff | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Staff | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<StaffForm>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [toggleError, setToggleError] = useState<string | null>(null);
async function load() {
const [staffRes, meRes] = await Promise.all([
fetch("/api/staff?includeInactive=true"),
fetch("/api/staff/me"),
]);
if (!staffRes.ok) throw new Error(`HTTP ${staffRes.status}`);
if (!meRes.ok) throw new Error(`HTTP ${meRes.status}`);
setStaff((await staffRes.json()) as Staff[]);
setCurrentUser((await meRes.json()) as Staff);
}
useEffect(() => {
load()
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, []);
function openNew() {
setEditing(null);
setForm(EMPTY_FORM);
setFormError(null);
setShowForm(true);
}
function openEdit(s: Staff) {
setEditing(s);
setForm({ name: s.name, email: s.email, role: s.role });
setFormError(null);
setShowForm(true);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setFormError(null);
try {
const res = editing
? await fetch(`/api/staff/${editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: form.name, role: form.role }) })
: await fetch("/api/staff", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowForm(false);
await load();
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
}
async function toggleActive(s: Staff) {
setTogglingId(s.id);
setToggleError(null);
try {
const res = await fetch(`/api/staff/${s.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active: !s.active }) });
if (!res.ok) {
const err = (await res.json()) as { error?: string };
setToggleError(err.error ?? `HTTP ${res.status}`);
return;
}
await load();
} finally {
setTogglingId(null);
}
}
async function toggleSuperUser(s: Staff) {
setTogglingId(s.id);
setToggleError(null);
try {
const res = await fetch(`/api/staff/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isSuperUser: !s.isSuperUser }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
setToggleError(err.error ?? `HTTP ${res.status}`);
return;
}
await load();
} finally {
setTogglingId(null);
}
}
const isLastSuperUser = (s: Staff) =>
s.isSuperUser && staff.filter((st) => st.isSuperUser).length === 1;
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" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<h1 style={{ margin: 0 }}>Staff</h1>
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}>
+ Add Staff
</button>
</div>
{toggleError && (
<p style={{ color: "red", marginBottom: "0.5rem" }}>{toggleError}</p>
)}
{staff.length === 0 ? (
<p>No staff members yet.</p>
) : (
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", overflow: "hidden", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ background: "#f8fafc" }}>
{["Name", "Email", "Role", "Super User", "Status", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0.55rem 0.75rem", borderBottom: "1px solid #e5e7eb", fontSize: 11, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.04em" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{staff.map((s) => (
<tr key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
<td style={tdStyle}>{s.name}</td>
<td style={tdStyle}>{s.email}</td>
<td style={tdStyle}><span style={{ textTransform: "capitalize" }}>{s.role}</span></td>
<td style={tdStyle}>
{currentUser?.isSuperUser ? (
<button
onClick={() => toggleSuperUser(s)}
disabled={togglingId === s.id || isLastSuperUser(s)}
title={isLastSuperUser(s) ? "Cannot revoke the last super user" : s.isSuperUser ? "Revoke super user" : "Grant super user"}
style={{
position: "relative",
width: 36,
height: 20,
borderRadius: 10,
border: "1px solid",
borderColor: s.isSuperUser ? "#f59e0b" : "#d1d5db",
background: s.isSuperUser ? "#fef3c7" : "#fff",
cursor: togglingId === s.id || isLastSuperUser(s) ? "not-allowed" : "pointer",
padding: 0,
display: "inline-flex",
alignItems: "center",
opacity: togglingId === s.id || isLastSuperUser(s) ? 0.6 : 1,
}}
>
<span style={{
position: "absolute",
left: s.isSuperUser ? 17 : 2,
width: 14,
height: 14,
borderRadius: 7,
background: s.isSuperUser ? "#f59e0b" : "#d1d5db",
transition: "left 0.15s ease",
}} />
{togglingId === s.id && (
<span style={{ position: "absolute", fontSize: 9, color: "#92400e", fontWeight: 700 }}></span>
)}
</button>
) : (
s.isSuperUser ? (
<span style={{ padding: "2px 8px", borderRadius: 12, fontSize: 11, fontWeight: 600, background: "#fef3c7", color: "#92400e" }}>Super User</span>
) : (
<span style={{ color: "#9ca3af", fontSize: 13 }}></span>
)
)}
</td>
<td style={tdStyle}>
<button
onClick={() => toggleActive(s)}
disabled={togglingId === s.id || isLastSuperUser(s)}
title={isLastSuperUser(s) ? "Cannot deactivate the last super user" : s.active ? "Deactivate" : "Activate"}
style={{
position: "relative",
width: 36,
height: 20,
borderRadius: 10,
border: "1px solid",
borderColor: s.active ? "#10b981" : "#d1d5db",
background: s.active ? "#d1fae5" : "#fff",
cursor: togglingId === s.id || isLastSuperUser(s) ? "not-allowed" : "pointer",
padding: 0,
display: "inline-flex",
alignItems: "center",
opacity: togglingId === s.id || isLastSuperUser(s) ? 0.6 : 1,
}}
>
<span style={{
position: "absolute",
left: s.active ? 17 : 2,
width: 14,
height: 14,
borderRadius: 7,
background: s.active ? "#10b981" : "#d1d5db",
transition: "left 0.15s ease",
}} />
{togglingId === s.id && (
<span style={{ position: "absolute", fontSize: 9, color: s.active ? "#065f46" : "#6b7280", fontWeight: 700 }}></span>
)}
</button>
</td>
<td style={{ ...tdStyle, whiteSpace: "nowrap" }}>
<button onClick={() => openEdit(s)} style={btnStyle}>Edit</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showForm && (
<div
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) setShowForm(false); }}
>
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 400, width: "calc(100% - 2rem)", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
<h2 style={{ marginTop: 0 }}>{editing ? "Edit Staff" : "New Staff Member"}</h2>
<form onSubmit={submit}>
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Full name</label>
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
</div>
{!editing && (
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Email</label>
<input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required style={inputStyle} />
</div>
)}
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Role</label>
<select value={form.role} onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as StaffForm["role"] }))} style={inputStyle}>
<option value="groomer">Groomer</option>
<option value="receptionist">Receptionist</option>
<option value="manager">Manager</option>
</select>
</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…" : editing ? "Save Changes" : "Add Staff"}
</button>
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>Cancel</button>
</div>
</form>
</div>
</div>
)}
</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" };
const labelStyle: React.CSSProperties = { display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" };
const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6" };