feat: add health alerts field and delete actions for pets and clients (#25)
- Add health_alerts column to pets table (migration 0001) - Update DB schema, shared types, and API Zod validation - Show health alerts prominently (red badge) in pet cards - Add health alerts field to pet form with allergy/condition placeholder - Add delete button per pet with confirmation dialog - Add delete client button with cascade warning Closes groombook/groombook#2 Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #25.
This commit is contained in:
committed by
GitHub
parent
43e50255ec
commit
eb9255eee0
@@ -12,6 +12,7 @@ const createPetSchema = z.object({
|
||||
breed: z.string().max(200).optional(),
|
||||
weightKg: z.number().positive().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
healthAlerts: z.string().max(2000).optional(),
|
||||
groomingNotes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -17,11 +17,12 @@ interface PetForm {
|
||||
breed: string;
|
||||
weightStr: string;
|
||||
dob: string;
|
||||
healthAlerts: string;
|
||||
groomingNotes: string;
|
||||
}
|
||||
|
||||
const EMPTY_CLIENT: ClientForm = { name: "", email: "", phone: "", address: "", notes: "" };
|
||||
const EMPTY_PET: PetForm = { name: "", species: "Dog", breed: "", weightStr: "", dob: "", groomingNotes: "" };
|
||||
const EMPTY_PET: PetForm = { name: "", species: "Dog", breed: "", weightStr: "", dob: "", healthAlerts: "", groomingNotes: "" };
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +48,8 @@ export function ClientsPage() {
|
||||
const [petForm, setPetForm] = useState<PetForm>(EMPTY_PET);
|
||||
const [petFormError, setPetFormError] = useState<string | null>(null);
|
||||
const [savingPet, setSavingPet] = useState(false);
|
||||
const [deletingPetId, setDeletingPetId] = useState<string | null>(null);
|
||||
const [deletingClient, setDeletingClient] = useState(false);
|
||||
|
||||
async function loadClients() {
|
||||
const r = await fetch("/api/clients");
|
||||
@@ -133,12 +136,50 @@ export function ClientsPage() {
|
||||
name: p.name, species: p.species, breed: p.breed ?? "",
|
||||
weightStr: p.weightKg != null ? String(p.weightKg) : "",
|
||||
dob: p.dateOfBirth ? p.dateOfBirth.slice(0, 10) : "",
|
||||
healthAlerts: p.healthAlerts ?? "",
|
||||
groomingNotes: p.groomingNotes ?? "",
|
||||
});
|
||||
setPetFormError(null);
|
||||
setShowPetForm(true);
|
||||
}
|
||||
|
||||
async function deletePet(petId: string) {
|
||||
if (!selectedClient) return;
|
||||
if (!window.confirm("Delete this pet? This cannot be undone.")) return;
|
||||
setDeletingPetId(petId);
|
||||
try {
|
||||
const res = await fetch(`/api/pets/${petId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await loadPets(selectedClient.id);
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to delete pet");
|
||||
} finally {
|
||||
setDeletingPetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(clientId: string) {
|
||||
if (!window.confirm("Delete this client and all their pets? This cannot be undone.")) return;
|
||||
setDeletingClient(true);
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${clientId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setSelectedClient(null);
|
||||
setPets([]);
|
||||
await loadClients();
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to delete client");
|
||||
} finally {
|
||||
setDeletingClient(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPet(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedClient) return;
|
||||
@@ -152,6 +193,7 @@ export function ClientsPage() {
|
||||
breed: petForm.breed || undefined,
|
||||
weightKg: petForm.weightStr ? parseFloat(petForm.weightStr) : undefined,
|
||||
dateOfBirth: petForm.dob ? new Date(petForm.dob).toISOString() : undefined,
|
||||
healthAlerts: petForm.healthAlerts || undefined,
|
||||
groomingNotes: petForm.groomingNotes || undefined,
|
||||
};
|
||||
const res = editingPet
|
||||
@@ -233,9 +275,18 @@ export function ClientsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => openEditClient(selectedClient)} style={{ ...btnStyle, marginLeft: "auto" }}>
|
||||
Edit client
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginLeft: "auto" }}>
|
||||
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
|
||||
Edit client
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void deleteClient(selectedClient.id); }}
|
||||
disabled={deletingClient}
|
||||
style={{ ...btnStyle, color: "#dc2626", borderColor: "#fca5a5" }}
|
||||
>
|
||||
{deletingClient ? "Deleting…" : "Delete client"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||
@@ -255,13 +306,27 @@ export function ClientsPage() {
|
||||
<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>
|
||||
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
|
||||
<div style={{ display: "flex", gap: "0.3rem" }}>
|
||||
<button onClick={() => openEditPet(p)} style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11 }}>Edit</button>
|
||||
<button
|
||||
onClick={() => { void deletePet(p.id); }}
|
||||
disabled={deletingPetId === p.id}
|
||||
style={{ ...btnStyle, padding: "0.15rem 0.5rem", fontSize: 11, color: "#dc2626", borderColor: "#fca5a5" }}
|
||||
>
|
||||
{deletingPetId === p.id ? "…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.2rem" }}>
|
||||
{p.species}{p.breed ? ` · ${p.breed}` : ""}
|
||||
</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}
|
||||
@@ -331,6 +396,9 @@ export function ClientsPage() {
|
||||
<Field label="Date of birth (optional)">
|
||||
<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" }} />
|
||||
</Field>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "pets" ADD COLUMN "health_alerts" text;
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1773771452946,
|
||||
"tag": "0000_colossal_colossus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1742241600000,
|
||||
"tag": "0001_pet_health_alerts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const pets = pgTable("pets", {
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Pet {
|
||||
breed: string | null;
|
||||
weightKg: number | null;
|
||||
dateOfBirth: string | null;
|
||||
healthAlerts: string | null;
|
||||
groomingNotes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user