From 8ecbfbeee48ecf80f0f8dfdff4c8c874083c419e Mon Sep 17 00:00:00 2001 From: "the-dogfather-cto[bot]" <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:23:09 +0000 Subject: [PATCH] fix(GRO-743): add dedicated client detail route with unconditional data fetch (#316) Direct navigation to /admin/clients/{id} now: - Fetches GET /api/clients/{id} on mount (unconditional) - Fetches GET /api/pets?clientId= on mount - Shows loading state while fetching - Shows error state on failure (401/404/5xx) - Preserves existing link-based navigation from ClientsPage Added ClientDetailPage.tsx as a standalone route component. Added 3 E2E tests covering direct nav, loading state, and error state. Co-authored-by: Test User Co-authored-by: Paperclip --- apps/e2e/tests/clients.spec.ts | 49 +++++ apps/web/src/App.tsx | 2 + apps/web/src/pages/ClientDetailPage.tsx | 236 ++++++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 apps/web/src/pages/ClientDetailPage.tsx diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 64cbcbc..eb766f1 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -63,3 +63,52 @@ test("clicking a client shows their details", async ({ page }) => { // Email appears in both the list row and the detail panel once selected await expect(page.getByText("alice@example.com")).toHaveCount(2); }); + +test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => { + // Mock individual client fetch for direct navigation + await page.route("/api/clients/client-1", (route) => + route.fulfill({ json: MOCK_CLIENTS[0] }) + ); + // Mock pets for this client + await page.route("/api/pets**", (route) => + route.fulfill({ json: [] }) + ); + + await page.goto("/admin/clients/client-1"); + // Client name must be visible without any clicking + await expect(page.getByText("Alice Johnson")).toBeVisible(); + // Should show back to list link + await expect(page.getByText("← Back to list")).toBeVisible(); +}); + +test("direct URL navigation shows loading then client", async ({ page }) => { + let resolvePets: (value: unknown) => void; + const petsPromise = new Promise((resolve) => { resolvePets = resolve; }); + + await page.route("/api/clients/client-1", (route) => + route.fulfill({ json: MOCK_CLIENTS[0] }) + ); + await page.route("/api/pets**", async (route) => { + await petsPromise; + await route.fulfill({ json: [] }); + }); + + const navigationPromise = page.goto("/admin/clients/client-1"); + // Should show loading state briefly + await expect(page.getByText("Loading client…")).toBeVisible(); + // Resolve pets and wait for navigation + resolvePets!(); + await navigationPromise; + // After data loads, client name is shown + await expect(page.getByText("Alice Johnson")).toBeVisible(); +}); + +test("direct URL navigation shows error state on failure", async ({ page }) => { + await page.route("/api/clients/nonexistent", (route) => + route.fulfill({ status: 404, json: { error: "Client not found" } }) + ); + + await page.goto("/admin/clients/nonexistent"); + await expect(page.getByText(/client not found/i)).toBeVisible(); + await expect(page.getByText("← Back to clients")).toBeVisible(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 83e95d6..ea51314 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r import { useEffect, useState } from "react"; import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; +import { ClientDetailPage } from "./pages/ClientDetailPage.js"; import { ServicesPage } from "./pages/Services.js"; import { StaffPage } from "./pages/Staff.js"; import { InvoicesPage } from "./pages/Invoices.js"; @@ -296,6 +297,7 @@ function AdminLayout() { } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/pages/ClientDetailPage.tsx b/apps/web/src/pages/ClientDetailPage.tsx new file mode 100644 index 0000000..fdb9d19 --- /dev/null +++ b/apps/web/src/pages/ClientDetailPage.tsx @@ -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(null); + const [pets, setPets] = useState([]); + const [visitLogs, setVisitLogs] = useState>({}); + const [logsLoading, setLogsLoading] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [photoRevisions, setPhotoRevisions] = useState>({}); + + 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 ( +
+ Loading client… +
+ ); + } + + if (error || !client) { + return ( +
+
+ ← Back to clients +
+
+ {error ?? "Client not found"} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

{client.name}

+ {client.status === "disabled" && ( + + Disabled + + )} +
+ {client.email &&
{client.email}
} + {client.phone &&
{client.phone}
} + {client.address &&
{client.address}
} + {client.notes && ( +
+ {client.notes} +
+ )} +
+ + ← Back to list + +
+ + {/* Pets */} +
+

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 */} + {(() => { + const logs = visitLogs[p.id]; + const loadingLogs = logsLoading[p.id]; + return ( +
+
+
VISIT HISTORY
+ {!logs && !loadingLogs && ( + + )} +
+ {loadingLogs &&
Loading…
} + {logs && logs.length === 0 &&
No visits yet
} + {logs && logs.length > 0 && ( + <> + {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
+ )} + + )} +
+ ); + })()} +
+ ))} +
+ )} +
+ ); +}