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:
@@ -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",
|
||||
};
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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",
|
||||
};
|
||||
@@ -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} · {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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { SetupWizard } from "./SetupWizard.tsx";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
Reference in New Issue
Block a user