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:
committed by
GitHub
parent
f47717dfd8
commit
14ed19497f
@@ -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}`);
|
||||
|
||||
@@ -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(),
|
||||
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
@@ -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 & 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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user