Files
Moonbase/src/components/animals/animal-dialog.tsx
tonym 39be24c2fc v0.14.0 — Animals & Pets
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>
2026-06-16 00:23:53 -05:00

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>} />
);
}