Livestock and household pets as leaves on the Location tree, mirroring Garden. - Animal model (species/breed/sex/birthdate/location, soft-delete) + AnimalLog (feeding, vet, weight, medication, breeding, acquired, died); Task.animalId for recurring care reminders - API-first /api/v1/animals (CRUD) + logs, animals:read/write scopes; animals service shared with the coming MCP - Animals pages: list grouped by species with auto-computed age, detail with facts + latest weight + care log; add/edit/remove + log entry. Nav entry. Backup/restore cover the new tables - Pure age.ts (newborn/days/months/years) with tests; verified end-to-end against a running server (create, vet + weigh-in logs, read-back) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.5 KiB
TypeScript
169 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Plus, Pencil } from "lucide-react";
|
|
import { COMMON_SPECIES } from "@/lib/animals/animal-fields";
|
|
|
|
export type LocationOpt = { id: string; name: string };
|
|
|
|
export type AnimalFields = {
|
|
id?: string;
|
|
name: string;
|
|
species: string;
|
|
breed: string;
|
|
sex: string;
|
|
locationId: string;
|
|
birthdate: string;
|
|
acquiredAt: string;
|
|
source: string;
|
|
notes: string;
|
|
};
|
|
|
|
export function blankAnimal(): AnimalFields {
|
|
return { name: "", species: "", breed: "", sex: "", locationId: "", birthdate: "", acquiredAt: "", source: "", notes: "" };
|
|
}
|
|
|
|
const NONE = "__none__";
|
|
|
|
function AnimalDialog({ trigger, title, initial, locations }: {
|
|
trigger: React.ReactNode; title: string; initial: AnimalFields; locations: LocationOpt[];
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [f, setF] = useState<AnimalFields>(initial);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const router = useRouter();
|
|
const { toast } = useToast();
|
|
|
|
function set<K extends keyof AnimalFields>(k: K, v: AnimalFields[K]) {
|
|
setF((p) => ({ ...p, [k]: v }));
|
|
}
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!f.name.trim()) { setError("Please give the animal a name."); return; }
|
|
setError("");
|
|
setBusy(true);
|
|
const payload = {
|
|
name: f.name.trim(),
|
|
species: f.species.trim() || undefined,
|
|
breed: f.breed.trim() || undefined,
|
|
sex: f.sex.trim() || undefined,
|
|
locationId: f.locationId || null,
|
|
birthdate: f.birthdate || null,
|
|
acquiredAt: f.acquiredAt || null,
|
|
source: f.source.trim() || undefined,
|
|
notes: f.notes.trim() || undefined,
|
|
};
|
|
const res = await fetch(f.id ? `/api/v1/animals/${f.id}` : "/api/v1/animals", {
|
|
method: f.id ? "PATCH" : "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
setBusy(false);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
|
|
return;
|
|
}
|
|
toast({ title: f.id ? "Saved!" : "Added!" });
|
|
setOpen(false);
|
|
router.refresh();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<span onClick={() => { setF(initial); setOpen(true); }}>{trigger}</span>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Name *</Label>
|
|
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Bella" />
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Species</Label>
|
|
<Input list="species-list" value={f.species} onChange={(e) => set("species", e.target.value)} placeholder="Dog" />
|
|
<datalist id="species-list">
|
|
{COMMON_SPECIES.map((s) => <option key={s} value={s} />)}
|
|
</datalist>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Breed</Label>
|
|
<Input value={f.breed} onChange={(e) => set("breed", e.target.value)} placeholder="Border collie" />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Sex</Label>
|
|
<Input value={f.sex} onChange={(e) => set("sex", e.target.value)} placeholder="Female" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Location</Label>
|
|
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
|
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>—</SelectItem>
|
|
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Birthdate</Label>
|
|
<Input type="date" value={f.birthdate} onChange={(e) => set("birthdate", e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Acquired</Label>
|
|
<Input type="date" value={f.acquiredAt} onChange={(e) => set("acquiredAt", e.target.value)} />
|
|
</div>
|
|
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Where from?</Label>
|
|
<Input value={f.source} onChange={(e) => set("source", e.target.value)} placeholder="Breeder, shelter, hatchery…" />
|
|
</div>
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Notes</Label>
|
|
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
|
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AddAnimalButton({ locations }: { locations: LocationOpt[] }) {
|
|
return (
|
|
<AnimalDialog title="Add an animal" initial={blankAnimal()} locations={locations}
|
|
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add animal</Button>} />
|
|
);
|
|
}
|
|
|
|
export function EditAnimalButton({ animal, locations }: { animal: AnimalFields; locations: LocationOpt[] }) {
|
|
return (
|
|
<AnimalDialog title="Edit animal" initial={animal} locations={locations}
|
|
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
|
|
);
|
|
}
|