From 14ed19497f676d70bc00ce71ebaffaa8684b7d5c Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:46:40 +0000 Subject: [PATCH] feat: detailed pet profile attributes and grooming visit history (closes #13) - Add cut_style, shampoo_preference, special_care_notes, custom_fields columns to pets table - Add grooming_visit_logs table to track per-visit grooming details (cut, products, notes) - Extend pets API to accept and return new profile fields - Add /api/grooming-logs endpoint (GET by petId, POST, DELETE) - Update Pet type with new fields; add GroomingVisitLog type - Update Clients page: grooming preferences section in pet card, "Log visit" button, visit history panel showing last 3 visits, expanded pet form with grooming preferences Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- apps/api/src/index.ts | 2 + apps/api/src/routes/groomingLogs.ts | 56 ++++ apps/api/src/routes/pets.ts | 10 +- apps/web/src/pages/Clients.tsx | 253 +++++++++++++++++- .../0006_pet_profile_attributes.sql | 30 +++ packages/db/migrations/meta/_journal.json | 14 + packages/db/src/schema.ts | 23 ++ packages/types/src/index.ts | 16 ++ 8 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/routes/groomingLogs.ts create mode 100644 packages/db/migrations/0006_pet_profile_attributes.sql diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 40ca8ca..ed494a2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,6 +11,7 @@ import { invoicesRouter } from "./routes/invoices.js"; import { bookRouter } from "./routes/book.js"; import { reportsRouter } from "./routes/reports.js"; import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; +import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { authMiddleware } from "./middleware/auth.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -44,6 +45,7 @@ api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); api.route("/appointment-groups", appointmentGroupsRouter); +api.route("/grooming-logs", groomingLogsRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/groomingLogs.ts b/apps/api/src/routes/groomingLogs.ts new file mode 100644 index 0000000..a89c2ed --- /dev/null +++ b/apps/api/src/routes/groomingLogs.ts @@ -0,0 +1,56 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db"; + +export const groomingLogsRouter = new Hono(); + +const createLogSchema = z.object({ + petId: z.string().uuid(), + appointmentId: z.string().uuid().optional(), + staffId: z.string().uuid().optional(), + cutStyle: z.string().max(500).optional(), + productsUsed: z.string().max(1000).optional(), + notes: z.string().max(2000).optional(), + groomedAt: z.string().datetime().optional(), +}); + +// GET /api/grooming-logs?petId= +groomingLogsRouter.get("/", async (c) => { + const db = getDb(); + const petId = c.req.query("petId"); + if (!petId) return c.json({ error: "petId is required" }, 400); + const rows = await db + .select() + .from(groomingVisitLogs) + .where(eq(groomingVisitLogs.petId, petId)) + .orderBy(desc(groomingVisitLogs.groomedAt)); + return c.json(rows); +}); + +groomingLogsRouter.post( + "/", + zValidator("json", createLogSchema), + async (c) => { + const db = getDb(); + const { groomedAt, ...rest } = c.req.valid("json"); + const [row] = await db + .insert(groomingVisitLogs) + .values({ + ...rest, + groomedAt: groomedAt ? new Date(groomedAt) : new Date(), + }) + .returning(); + return c.json(row, 201); + } +); + +groomingLogsRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(groomingVisitLogs) + .where(eq(groomingVisitLogs.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index ad6eb29..914cf5c 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -14,6 +14,10 @@ const createPetSchema = z.object({ dateOfBirth: z.string().datetime().optional(), healthAlerts: z.string().max(2000).optional(), groomingNotes: z.string().max(2000).optional(), + cutStyle: z.string().max(500).optional(), + shampooPreference: z.string().max(500).optional(), + specialCareNotes: z.string().max(2000).optional(), + customFields: z.record(z.string(), z.string()).optional(), }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); @@ -42,13 +46,14 @@ petsRouter.get("/:id", async (c) => { petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); const [row] = await db .insert(pets) .values({ ...rest, weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + customFields: customFields ?? {}, }) .returning(); return c.json(row, 201); @@ -59,13 +64,14 @@ petsRouter.patch( zValidator("json", updatePetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); const [row] = await db .update(pets) .set({ ...rest, weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + ...(customFields !== undefined ? { customFields } : {}), updatedAt: new Date(), }) .where(eq(pets.id, c.req.param("id"))) diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index b768226..e28d424 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import type { Client, Pet } from "@groombook/types"; +import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; // ─── Forms ─────────────────────────────────────────────────────────────────── @@ -19,10 +19,24 @@ interface PetForm { 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: "" }; +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 ─────────────────────────────────────────────────────────────── @@ -51,6 +65,15 @@ export function ClientsPage() { const [deletingPetId, setDeletingPetId] = useState(null); const [deletingClient, setDeletingClient] = useState(false); + // 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() { const r = await fetch("/api/clients"); if (!r.ok) throw new Error(`HTTP ${r.status}`); @@ -70,6 +93,17 @@ export function ClientsPage() { 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); @@ -138,6 +172,9 @@ export function ClientsPage() { 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); @@ -195,6 +232,9 @@ export function ClientsPage() { 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) }) @@ -212,6 +252,50 @@ export function ClientsPage() { } } + // ── 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()) || @@ -301,13 +385,19 @@ export function ClientsPage() { ) : pets.length === 0 ? (

No pets on file for this client.

) : ( -
+
{pets.map((p) => (
{p.name}
+
{p.weightKg != null &&
{p.weightKg} kg
} {p.dateOfBirth &&
Born {new Date(p.dateOfBirth).toLocaleDateString()}
} + {p.healthAlerts && (
⚠ Health alerts: {p.healthAlerts}
)} - {p.groomingNotes && ( -
- Notes: {p.groomingNotes} + + {/* 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
+ )} +
+ ); + })()}
))}
@@ -397,11 +530,47 @@ export function ClientsPage() { setPetForm((f) => ({ ...f, dob: e.target.value }))} style={inputStyle} /> -