import { useEffect, useState, useCallback, useRef } 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [selectedClient, setSelectedClient] = useState(null); const [pets, setPets] = useState([]); const [petsLoading, setPetsLoading] = useState(false); const clientRowRefs = useRef>(new Map()); // Client form const [showClientForm, setShowClientForm] = useState(false); const [editingClient, setEditingClient] = useState(null); const [clientForm, setClientForm] = useState(EMPTY_CLIENT); const [clientFormError, setClientFormError] = useState(null); const [savingClient, setSavingClient] = useState(false); // Pet form const [showPetForm, setShowPetForm] = useState(false); const [editingPet, setEditingPet] = useState(null); const [petForm, setPetForm] = useState(EMPTY_PET); const [petFormError, setPetFormError] = useState(null); const [savingPet, setSavingPet] = useState(false); const [deletingPetId, setDeletingPetId] = useState(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>({}); const handlePhotoUploaded = useCallback((petId: string) => { setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 })); }, []); // Visit log const [logPetId, setLogPetId] = useState(null); const [visitLogs, setVisitLogs] = useState>({}); const [logsLoading, setLogsLoading] = useState>({}); const [showLogForm, setShowLogForm] = useState(false); const [logForm, setLogForm] = useState(EMPTY_VISIT_LOG); const [logFormError, setLogFormError] = useState(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=) 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).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

Loading…

; if (error) return

Error: {error}

; return (
{/* ── Client list ── */}

Clients

setSearch(e.target.value)} style={{ ...inputStyle, marginBottom: "0.5rem" }} /> {filtered.length === 0 &&

No clients found.

} {filtered.map((c) => (
{ 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", }} >
{c.name} {c.status === "disabled" && ( Disabled )}
{c.email &&
{c.email}
} {c.phone &&
{c.phone}
}
))}
{/* ── Client detail ── */} {selectedClient ? (

{selectedClient.name} {selectedClient.status === "disabled" && ( Disabled )}

{selectedClient.email &&
{selectedClient.email}
} {selectedClient.phone &&
{selectedClient.phone}
} {selectedClient.address &&
{selectedClient.address}
} {selectedClient.notes && (
{selectedClient.notes}
)}
{selectedClient.status === "active" ? ( ) : ( )}

Pets

{petsLoading ? (

Loading pets…

) : pets.length === 0 ? (

No pets on file for this client.

) : (
{pets.map((p) => (
{/* ── Photo + header ── */}
{p.name}
{p.species}{p.breed ? ` · ${p.breed}` : ""}
{p.weightKg != null &&
{p.weightKg} kg
} {p.dateOfBirth &&
Born {new Date(p.dateOfBirth).toLocaleDateString()}
}
handlePhotoUploaded(p.id)} />
{p.healthAlerts && (
⚠ Health alerts: {p.healthAlerts}
)} {/* Grooming preferences */} {(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
{p.cutStyle && (
Cut: {p.cutStyle}
)} {p.shampooPreference && (
Shampoo: {p.shampooPreference}
)} {p.specialCareNotes && (
Special care: {p.specialCareNotes}
)} {p.groomingNotes && (
Notes: {p.groomingNotes}
)}
)} {/* Visit history (loaded on demand) */} {(() => { const logs = visitLogs[p.id]; if (!logs || logs.length === 0) return null; return (
VISIT HISTORY
{logs.slice(0, 3).map((log) => (
{new Date(log.groomedAt).toLocaleDateString()} {log.cutStyle && · {log.cutStyle}} {log.notes && · {log.notes}}
))} {logs.length > 3 && (
+{logs.length - 3} more visits
)}
); })()}
))}
)}
) : (
Select a client to view details
)} {/* ── Client modal ── */} {showClientForm && ( setShowClientForm(false)}>

{editingClient ? "Edit Client" : "New Client"}

setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> setClientForm((f) => ({ ...f, email: e.target.value }))} style={inputStyle} /> setClientForm((f) => ({ ...f, phone: e.target.value }))} style={inputStyle} /> setClientForm((f) => ({ ...f, address: e.target.value }))} style={inputStyle} />