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 <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #32.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 21:46:40 +00:00
committed by GitHub
parent f47717dfd8
commit 14ed19497f
8 changed files with 392 additions and 12 deletions
+2
View File
@@ -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}`);
+56
View File
@@ -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=<uuid>
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 });
});
+8 -2
View File
@@ -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")))
+243 -10
View File
@@ -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<string | null>(null);
const [deletingClient, setDeletingClient] = useState(false);
// 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() {
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<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);
@@ -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 ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: "0.75rem" }}>
<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 #e2e8f0", borderRadius: 8, padding: "0.75rem" }}>
<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}
@@ -322,16 +412,59 @@ export function ClientsPage() {
</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>}
{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>
)}
{p.groomingNotes && (
<div style={{ fontSize: 12, marginTop: "0.35rem", color: "#374151" }}>
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
{/* 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>
@@ -397,11 +530,47 @@ export function ClientsPage() {
<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>
<Field label="Grooming notes (optional)">
<textarea value={petForm.groomingNotes} onChange={(e) => setPetForm((f) => ({ ...f, groomingNotes: e.target.value }))} rows={2} style={{ ...inputStyle, resize: "vertical" }} />
<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" }}>
@@ -412,6 +581,70 @@ export function ClientsPage() {
</form>
</Modal>
)}
{/* ── Visit log modal ── */}
{showLogForm && logPetId && (
<Modal onClose={() => setShowLogForm(false)}>
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history</p>}
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
<div style={{ marginBottom: "1rem" }}>
<div style={{ fontSize: 12, fontWeight: 700, color: "#6b7280", marginBottom: "0.4rem", textTransform: "uppercase" }}>Past Visits</div>
{visitLogs[logPetId].slice(0, 5).map((log) => (
<div key={log.id} style={{ fontSize: 12, borderLeft: "2px solid #e2e8f0", paddingLeft: "0.5rem", marginBottom: "0.3rem", color: "#374151" }}>
<strong>{new Date(log.groomedAt).toLocaleDateString()}</strong>
{log.cutStyle && <span> · {log.cutStyle}</span>}
{log.productsUsed && <span> · {log.productsUsed}</span>}
{log.notes && <div style={{ color: "#6b7280" }}>{log.notes}</div>}
</div>
))}
</div>
)}
<form onSubmit={submitVisitLog}>
<Field label="Date &amp; time">
<input
type="datetime-local"
value={logForm.groomedAt}
onChange={(e) => setLogForm((f) => ({ ...f, groomedAt: e.target.value }))}
style={inputStyle}
required
/>
</Field>
<Field label="Cut style (optional)">
<input
value={logForm.cutStyle}
onChange={(e) => setLogForm((f) => ({ ...f, cutStyle: e.target.value }))}
style={inputStyle}
placeholder="e.g. Puppy cut, Kennel cut"
/>
</Field>
<Field label="Products used (optional)">
<input
value={logForm.productsUsed}
onChange={(e) => setLogForm((f) => ({ ...f, productsUsed: e.target.value }))}
style={inputStyle}
placeholder="e.g. Oatmeal shampoo, leave-in conditioner"
/>
</Field>
<Field label="Notes (optional)">
<textarea
value={logForm.notes}
onChange={(e) => setLogForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
style={{ ...inputStyle, resize: "vertical" }}
placeholder="Anything notable about this visit"
/>
</Field>
{logFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{logFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "#3b82f6", color: "#fff", borderColor: "#3b82f6" }}>
{savingLog ? "Saving…" : "Save Visit Log"}
</button>
<button type="button" onClick={() => setShowLogForm(false)} style={btnStyle}>Cancel</button>
</div>
</form>
</Modal>
)}
</div>
);
}
@@ -0,0 +1,30 @@
-- Extend pet profiles with grooming-specific attributes (closes groombook/groombook#13)
ALTER TABLE "pets"
ADD COLUMN "cut_style" text,
ADD COLUMN "shampoo_preference" text,
ADD COLUMN "special_care_notes" text,
ADD COLUMN "custom_fields" jsonb DEFAULT '{}' NOT NULL;
--> statement-breakpoint
CREATE TABLE "grooming_visit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pet_id" uuid NOT NULL,
"appointment_id" uuid,
"staff_id" uuid,
"cut_style" text,
"products_used" text,
"notes" text,
"groomed_at" timestamp NOT NULL DEFAULT now(),
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "grooming_visit_logs"
ADD CONSTRAINT "grooming_visit_logs_pet_id_pets_id_fk"
FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "grooming_visit_logs"
ADD CONSTRAINT "grooming_visit_logs_appointment_id_appointments_id_fk"
FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "grooming_visit_logs"
ADD CONSTRAINT "grooming_visit_logs_staff_id_staff_id_fk"
FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;
+14
View File
@@ -36,6 +36,20 @@
"when": 1773779939000,
"tag": "0004_reminder_logs",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1773783000000,
"tag": "0005_appointment_groups",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1773783600000,
"tag": "0006_pet_profile_attributes",
"breakpoints": true
}
]
}
+23
View File
@@ -1,6 +1,7 @@
import {
boolean,
integer,
jsonb,
numeric,
pgEnum,
pgTable,
@@ -68,6 +69,10 @@ export const pets = pgTable("pets", {
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
groomingNotes: text("grooming_notes"),
cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -194,3 +199,21 @@ export const reminderLogs = pgTable(
},
(t) => [unique().on(t.appointmentId, t.reminderType)]
);
export const groomingVisitLogs = pgTable("grooming_visit_logs", {
id: uuid("id").primaryKey().defaultRandom(),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "cascade" }),
appointmentId: uuid("appointment_id").references(() => appointments.id, {
onDelete: "set null",
}),
staffId: uuid("staff_id").references(() => staff.id, {
onDelete: "set null",
}),
cutStyle: text("cut_style"),
productsUsed: text("products_used"),
notes: text("notes"),
groomedAt: timestamp("groomed_at").notNull().defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
+16
View File
@@ -30,10 +30,26 @@ export interface Pet {
dateOfBirth: string | null;
healthAlerts: string | null;
groomingNotes: string | null;
cutStyle: string | null;
shampooPreference: string | null;
specialCareNotes: string | null;
customFields: Record<string, string>;
createdAt: string;
updatedAt: string;
}
export interface GroomingVisitLog {
id: string;
petId: string;
appointmentId: string | null;
staffId: string | null;
cutStyle: string | null;
productsUsed: string | null;
notes: string | null;
groomedAt: string;
createdAt: string;
}
export interface Service {
id: string;
name: string;