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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -11,6 +11,7 @@ import { invoicesRouter } from "./routes/invoices.js";
|
|||||||
import { bookRouter } from "./routes/book.js";
|
import { bookRouter } from "./routes/book.js";
|
||||||
import { reportsRouter } from "./routes/reports.js";
|
import { reportsRouter } from "./routes/reports.js";
|
||||||
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
||||||
|
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ api.route("/staff", staffRouter);
|
|||||||
api.route("/invoices", invoicesRouter);
|
api.route("/invoices", invoicesRouter);
|
||||||
api.route("/reports", reportsRouter);
|
api.route("/reports", reportsRouter);
|
||||||
api.route("/appointment-groups", appointmentGroupsRouter);
|
api.route("/appointment-groups", appointmentGroupsRouter);
|
||||||
|
api.route("/grooming-logs", groomingLogsRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -14,6 +14,10 @@ const createPetSchema = z.object({
|
|||||||
dateOfBirth: z.string().datetime().optional(),
|
dateOfBirth: z.string().datetime().optional(),
|
||||||
healthAlerts: z.string().max(2000).optional(),
|
healthAlerts: z.string().max(2000).optional(),
|
||||||
groomingNotes: 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 });
|
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||||
@@ -42,13 +46,14 @@ petsRouter.get("/:id", async (c) => {
|
|||||||
|
|
||||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { weightKg, dateOfBirth, ...rest } = c.req.valid("json");
|
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(pets)
|
.insert(pets)
|
||||||
.values({
|
.values({
|
||||||
...rest,
|
...rest,
|
||||||
weightKg: weightKg?.toString(),
|
weightKg: weightKg?.toString(),
|
||||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||||
|
customFields: customFields ?? {},
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return c.json(row, 201);
|
return c.json(row, 201);
|
||||||
@@ -59,13 +64,14 @@ petsRouter.patch(
|
|||||||
zValidator("json", updatePetSchema),
|
zValidator("json", updatePetSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { weightKg, dateOfBirth, ...rest } = c.req.valid("json");
|
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(pets)
|
.update(pets)
|
||||||
.set({
|
.set({
|
||||||
...rest,
|
...rest,
|
||||||
weightKg: weightKg?.toString(),
|
weightKg: weightKg?.toString(),
|
||||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||||
|
...(customFields !== undefined ? { customFields } : {}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(pets.id, c.req.param("id")))
|
.where(eq(pets.id, c.req.param("id")))
|
||||||
|
|||||||
+243
-10
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Client, Pet } from "@groombook/types";
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Forms ───────────────────────────────────────────────────────────────────
|
// ─── Forms ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -19,10 +19,24 @@ interface PetForm {
|
|||||||
dob: string;
|
dob: string;
|
||||||
healthAlerts: string;
|
healthAlerts: string;
|
||||||
groomingNotes: 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_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 ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,6 +65,15 @@ export function ClientsPage() {
|
|||||||
const [deletingPetId, setDeletingPetId] = useState<string | null>(null);
|
const [deletingPetId, setDeletingPetId] = useState<string | null>(null);
|
||||||
const [deletingClient, setDeletingClient] = useState(false);
|
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() {
|
async function loadClients() {
|
||||||
const r = await fetch("/api/clients");
|
const r = await fetch("/api/clients");
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
@@ -70,6 +93,17 @@ export function ClientsPage() {
|
|||||||
setPetsLoading(false);
|
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) {
|
function selectClient(c: Client) {
|
||||||
setSelectedClient(c);
|
setSelectedClient(c);
|
||||||
loadPets(c.id);
|
loadPets(c.id);
|
||||||
@@ -138,6 +172,9 @@ export function ClientsPage() {
|
|||||||
dob: p.dateOfBirth ? p.dateOfBirth.slice(0, 10) : "",
|
dob: p.dateOfBirth ? p.dateOfBirth.slice(0, 10) : "",
|
||||||
healthAlerts: p.healthAlerts ?? "",
|
healthAlerts: p.healthAlerts ?? "",
|
||||||
groomingNotes: p.groomingNotes ?? "",
|
groomingNotes: p.groomingNotes ?? "",
|
||||||
|
cutStyle: p.cutStyle ?? "",
|
||||||
|
shampooPreference: p.shampooPreference ?? "",
|
||||||
|
specialCareNotes: p.specialCareNotes ?? "",
|
||||||
});
|
});
|
||||||
setPetFormError(null);
|
setPetFormError(null);
|
||||||
setShowPetForm(true);
|
setShowPetForm(true);
|
||||||
@@ -195,6 +232,9 @@ export function ClientsPage() {
|
|||||||
dateOfBirth: petForm.dob ? new Date(petForm.dob).toISOString() : undefined,
|
dateOfBirth: petForm.dob ? new Date(petForm.dob).toISOString() : undefined,
|
||||||
healthAlerts: petForm.healthAlerts || undefined,
|
healthAlerts: petForm.healthAlerts || undefined,
|
||||||
groomingNotes: petForm.groomingNotes || undefined,
|
groomingNotes: petForm.groomingNotes || undefined,
|
||||||
|
cutStyle: petForm.cutStyle || undefined,
|
||||||
|
shampooPreference: petForm.shampooPreference || undefined,
|
||||||
|
specialCareNotes: petForm.specialCareNotes || undefined,
|
||||||
};
|
};
|
||||||
const res = editingPet
|
const res = editingPet
|
||||||
? await fetch(`/api/pets/${editingPet.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
? 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
|
const filtered = search
|
||||||
? clients.filter((c) =>
|
? clients.filter((c) =>
|
||||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
@@ -301,13 +385,19 @@ export function ClientsPage() {
|
|||||||
) : pets.length === 0 ? (
|
) : pets.length === 0 ? (
|
||||||
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
<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) => (
|
{pets.map((p) => (
|
||||||
<div key={p.id} style={{ border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem" }}>
|
<div key={p.id} style={{ border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem" }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
||||||
<div style={{ display: "flex", gap: "0.3rem" }}>
|
<div style={{ display: "flex", gap: "0.3rem" }}>
|
||||||
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
|
<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
|
<button
|
||||||
onClick={() => { void deletePet(p.id); }}
|
onClick={() => { void deletePet(p.id); }}
|
||||||
disabled={deletingPetId === p.id}
|
disabled={deletingPetId === p.id}
|
||||||
@@ -322,16 +412,59 @@ export function ClientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</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.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
|
||||||
|
|
||||||
{p.healthAlerts && (
|
{p.healthAlerts && (
|
||||||
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
|
<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}
|
<span style={{ fontWeight: 600 }}>⚠ Health alerts:</span> {p.healthAlerts}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{p.groomingNotes && (
|
|
||||||
<div style={{ fontSize: 12, marginTop: "0.35rem", color: "#374151" }}>
|
{/* Grooming preferences */}
|
||||||
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
|
{(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>
|
</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>
|
||||||
@@ -397,11 +530,47 @@ export function ClientsPage() {
|
|||||||
<input type="date" value={petForm.dob} onChange={(e) => setPetForm((f) => ({ ...f, dob: e.target.value }))} style={inputStyle} />
|
<input type="date" value={petForm.dob} onChange={(e) => setPetForm((f) => ({ ...f, dob: e.target.value }))} style={inputStyle} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Health alerts (allergies, conditions, medications)">
|
<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" />
|
<textarea
|
||||||
</Field>
|
value={petForm.healthAlerts}
|
||||||
<Field label="Grooming notes (optional)">
|
onChange={(e) => setPetForm((f) => ({ ...f, healthAlerts: e.target.value }))}
|
||||||
<textarea value={petForm.groomingNotes} onChange={(e) => setPetForm((f) => ({ ...f, groomingNotes: e.target.value }))} rows={2} style={{ ...inputStyle, resize: "vertical" }} />
|
rows={2}
|
||||||
|
style={{ ...inputStyle, resize: "vertical", borderColor: petForm.healthAlerts ? "#fca5a5" : undefined }}
|
||||||
|
placeholder="e.g. Allergic to lavender, heart condition, on medication X"
|
||||||
|
/>
|
||||||
</Field>
|
</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>}
|
{petFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{petFormError}</p>}
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
|
||||||
<button type="submit" disabled={savingPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
|
<button type="submit" disabled={savingPet} style={{ ...btnStyle, backgroundColor: "#10b981", color: "#fff", borderColor: "#10b981" }}>
|
||||||
@@ -412,6 +581,70 @@ export function ClientsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</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 & 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>
|
</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;
|
||||||
@@ -36,6 +36,20 @@
|
|||||||
"when": 1773779939000,
|
"when": 1773779939000,
|
||||||
"tag": "0004_reminder_logs",
|
"tag": "0004_reminder_logs",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
jsonb,
|
||||||
numeric,
|
numeric,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -68,6 +69,10 @@ export const pets = pgTable("pets", {
|
|||||||
dateOfBirth: timestamp("date_of_birth"),
|
dateOfBirth: timestamp("date_of_birth"),
|
||||||
healthAlerts: text("health_alerts"),
|
healthAlerts: text("health_alerts"),
|
||||||
groomingNotes: text("grooming_notes"),
|
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(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
@@ -194,3 +199,21 @@ export const reminderLogs = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
(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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,10 +30,26 @@ export interface Pet {
|
|||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
healthAlerts: string | null;
|
healthAlerts: string | null;
|
||||||
groomingNotes: string | null;
|
groomingNotes: string | null;
|
||||||
|
cutStyle: string | null;
|
||||||
|
shampooPreference: string | null;
|
||||||
|
specialCareNotes: string | null;
|
||||||
|
customFields: Record<string, string>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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 {
|
export interface Service {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user