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>
93 lines
3.8 KiB
TypeScript
93 lines
3.8 KiB
TypeScript
import Link from "next/link";
|
|
import { db } from "@/lib/db";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { PawPrint, MapPin, ChevronRight } from "lucide-react";
|
|
import { ageString } from "@/lib/animals/age";
|
|
import { AddAnimalButton } from "@/components/animals/animal-dialog";
|
|
|
|
export const metadata = { title: "Animals" };
|
|
|
|
const now = new Date();
|
|
|
|
export default async function AnimalsPage() {
|
|
const [animals, locations] = await Promise.all([
|
|
db.animal.findMany({
|
|
where: { active: true },
|
|
orderBy: { name: "asc" },
|
|
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
|
|
}),
|
|
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
|
]);
|
|
|
|
// Group by species ("Other" bucket last).
|
|
const groups = new Map<string, typeof animals>();
|
|
for (const a of animals) {
|
|
const key = a.species?.trim() || "Other";
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key)!.push(a);
|
|
}
|
|
const sorted = Array.from(groups.entries()).sort((a, b) =>
|
|
a[0] === "Other" ? 1 : b[0] === "Other" ? -1 : a[0].localeCompare(b[0]),
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="font-display text-2xl font-semibold">Animals & pets</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{animals.length} {animals.length === 1 ? "animal" : "animals"}
|
|
</p>
|
|
</div>
|
|
<AddAnimalButton locations={locations} />
|
|
</div>
|
|
|
|
{animals.length === 0 ? (
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
<PawPrint className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
|
<p className="font-medium">No animals yet</p>
|
|
<p className="text-sm mt-1">Add a pet or some livestock to start a care log.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{sorted.map(([species, list]) => (
|
|
<div key={species} className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{species}</p>
|
|
<div className="divide-y border rounded-lg overflow-hidden">
|
|
{list.map((a) => {
|
|
const age = ageString(a.birthdate, now);
|
|
return (
|
|
<Link key={a.id} href={`/animals/${a.id}`}
|
|
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
|
|
{a.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={a.imageUrl} alt="" className="h-9 w-9 rounded-full object-cover shrink-0" />
|
|
) : (
|
|
<span className="h-9 w-9 rounded-full bg-[hsl(var(--leaf)/.1)] flex items-center justify-center shrink-0">
|
|
<PawPrint className="h-4 w-4 text-[hsl(var(--leaf)/.6)]" />
|
|
</span>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">{a.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{[a.breed, age].filter(Boolean).join(" · ") || a.species}
|
|
</p>
|
|
</div>
|
|
{a.location?.name && (
|
|
<Badge variant="outline" className="gap-1 text-xs">
|
|
<MapPin className="h-2.5 w-2.5" />{a.location.name}
|
|
</Badge>
|
|
)}
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|