feat(07): Phase 7 — Health & Vet Tracking
Schema: WeightLog, VetVisit, Vaccine, HealthCard models Router: health.ts — CRUD for weight/vet/vaccines + health card token management Pages: - /pets/[petId]/health — owner-only dashboard (weight, vet visits, vaccines) - /health-card/[token] — public shareable health card (no auth required) Profile: Health button added for own pets; Edit + Health side by side Health data is owner-private; the Health Card link is the only public access point, shareable via token URL (vet, friends, sitters). Token can be regenerated to revoke access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d396a7bb4
commit
f1c798a4b0
7 files changed
+983
-7
No files matched your search
@@ -69,6 +69,12 @@ model Pet {
|
||||
conversationsAsB Conversation[] @relation("ConvPetB")
|
||||
sentMessages Message[] @relation("MessageSender")
|
||||
|
||||
// Phase 7 back-relations
|
||||
weightLogs WeightLog[]
|
||||
vetVisits VetVisit[]
|
||||
vaccines Vaccine[]
|
||||
healthCard HealthCard?
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
@@ -341,6 +347,55 @@ model Message {
|
||||
@@index([senderPetId])
|
||||
}
|
||||
|
||||
// ─── Phase 7 Models ──────────────────────────────────────────────────────────
|
||||
|
||||
model WeightLog {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
date DateTime @db.Date
|
||||
weightGrams Int // stored in grams; display as kg
|
||||
notes String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([petId, date(sort: Desc)])
|
||||
}
|
||||
|
||||
model VetVisit {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
date DateTime @db.Date
|
||||
reason String @db.VarChar(100)
|
||||
notes String? @db.VarChar(500)
|
||||
vetName String? @db.VarChar(100)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([petId, date(sort: Desc)])
|
||||
}
|
||||
|
||||
model Vaccine {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
name String @db.VarChar(100)
|
||||
dateGiven DateTime @db.Date
|
||||
nextDueDate DateTime? @db.Date
|
||||
notes String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([petId])
|
||||
}
|
||||
|
||||
model HealthCard {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String @unique
|
||||
token String @unique @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
|
||||
|
||||
enum NotificationType {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { HealthDashboard } from "@/components/health/HealthDashboard";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ petId: string }>;
|
||||
}
|
||||
|
||||
export default async function HealthPage({ params }: PageProps) {
|
||||
const { petId } = await params;
|
||||
|
||||
const [user, trpc] = await Promise.all([currentUser(), createTRPCCaller()]);
|
||||
|
||||
let pet;
|
||||
try {
|
||||
pet = await trpc.pets.getProfile({ petId });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!user || pet.ownerId !== user.id) {
|
||||
redirect(`/pets/${petId}`);
|
||||
}
|
||||
|
||||
return <HealthDashboard petId={petId} petName={pet.name} />;
|
||||
}
|
||||
@@ -78,14 +78,22 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Edit link — only own pet */}
|
||||
{/* Owner actions — edit + health */}
|
||||
{isOwnPet && (
|
||||
<Link
|
||||
href={`/pets/${pet.id}/edit`}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
Edit profile
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/pets/${pet.id}/edit`}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
Edit profile
|
||||
</Link>
|
||||
<Link
|
||||
href={`/pets/${pet.id}/health`}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
Health
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PawPrint, AlertTriangle } from "lucide-react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
function formatDate(d: Date | string) {
|
||||
return new Date(d).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeight(grams: number): string {
|
||||
if (grams < 1000) return `${grams} g`;
|
||||
return `${parseFloat((grams / 1000).toFixed(3))} kg`;
|
||||
}
|
||||
|
||||
function isOverdue(date: Date | string): boolean {
|
||||
return new Date(date) < new Date();
|
||||
}
|
||||
|
||||
export default async function HealthCardPage({ params }: PageProps) {
|
||||
const { token } = await params;
|
||||
|
||||
const card = await prisma.healthCard.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
pet: {
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
weightLogs: { orderBy: { date: "desc" }, take: 20 },
|
||||
vetVisits: { orderBy: { date: "desc" }, take: 10 },
|
||||
vaccines: { orderBy: { dateGiven: "desc" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!card) notFound();
|
||||
|
||||
const { pet } = card;
|
||||
const avatarUrl = getAvatarUrl(pet.avatarKey);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
{/* Header bar */}
|
||||
<div className="bg-card border-b px-4 py-3 flex items-center gap-2">
|
||||
<PawPrint className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
<span className="text-sm font-semibold text-foreground">PawFeed</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">Health Card</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[600px] mx-auto px-4 py-8 space-y-6">
|
||||
{/* Pet identity */}
|
||||
<div className="bg-card rounded-xl border p-6 flex items-center gap-4">
|
||||
<Avatar className="w-16 h-16 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{pet.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary">{pet.species.name}</Badge>
|
||||
{pet.breed && <Badge variant="outline">{pet.breed.name}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vaccines */}
|
||||
{pet.vaccines.length > 0 && (
|
||||
<div className="bg-card rounded-xl border p-5 space-y-3">
|
||||
<h2 className="text-base font-semibold">Vaccines</h2>
|
||||
<div className="space-y-2">
|
||||
{pet.vaccines.map((vac) => {
|
||||
const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate);
|
||||
return (
|
||||
<div key={vac.id} className="flex items-start justify-between gap-2 py-1.5">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{vac.name}</p>
|
||||
<p className="text-xs text-muted-foreground">Given {formatDate(vac.dateGiven)}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{vac.nextDueDate && (
|
||||
<Badge variant={overdue ? "destructive" : "secondary"} className="text-xs gap-1">
|
||||
{overdue && <AlertTriangle className="h-3 w-3" />}
|
||||
{overdue ? "Overdue" : `Due ${formatDate(vac.nextDueDate)}`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weight history */}
|
||||
{pet.weightLogs.length > 0 && (
|
||||
<div className="bg-card rounded-xl border p-5 space-y-3">
|
||||
<h2 className="text-base font-semibold">Weight History</h2>
|
||||
<div className="space-y-1">
|
||||
{pet.weightLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between py-1">
|
||||
<p className="text-sm text-muted-foreground">{formatDate(log.date)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{log.notes && <p className="text-xs text-muted-foreground">{log.notes}</p>}
|
||||
<p className="text-sm font-medium tabular-nums">{formatWeight(log.weightGrams)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vet visits */}
|
||||
{pet.vetVisits.length > 0 && (
|
||||
<div className="bg-card rounded-xl border p-5 space-y-3">
|
||||
<h2 className="text-base font-semibold">Vet Visits</h2>
|
||||
<div className="space-y-3">
|
||||
{pet.vetVisits.map((visit, i) => (
|
||||
<div key={visit.id}>
|
||||
{i > 0 && <Separator />}
|
||||
<div className="pt-2 first:pt-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-sm font-medium">{visit.reason}</p>
|
||||
<p className="text-xs text-muted-foreground shrink-0">{formatDate(visit.date)}</p>
|
||||
</div>
|
||||
{visit.vetName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{visit.vetName}</p>
|
||||
)}
|
||||
{visit.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{visit.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pet.vaccines.length === 0 && pet.weightLogs.length === 0 && pet.vetVisits.length === 0 && (
|
||||
<div className="bg-card rounded-xl border p-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">No health records have been added yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-xs text-muted-foreground text-center pb-4">
|
||||
Generated via PawFeed · {new Date().toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2, Share2, RefreshCw, Copy, Check, Loader2, AlertTriangle } from "lucide-react";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface HealthDashboardProps {
|
||||
petId: string;
|
||||
petName: string;
|
||||
}
|
||||
|
||||
function formatDate(d: Date | string) {
|
||||
return new Date(d).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeight(grams: number): string {
|
||||
if (grams < 1000) return `${grams} g`;
|
||||
const kg = grams / 1000;
|
||||
return `${parseFloat(kg.toFixed(3))} kg`;
|
||||
}
|
||||
|
||||
function isOverdue(date: Date | string): boolean {
|
||||
return new Date(date) < new Date();
|
||||
}
|
||||
|
||||
// ── Weight Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function WeightTab({ petId }: { petId: string }) {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [weightKg, setWeightKg] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const { data = [], isLoading } = useQuery(trpc.health.listWeightLogs.queryOptions({ petId }));
|
||||
|
||||
const addMutation = useMutation({
|
||||
...trpc.health.addWeightLog.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
|
||||
toast.success("Weight entry added");
|
||||
setShowForm(false);
|
||||
setWeightKg("");
|
||||
setNotes("");
|
||||
},
|
||||
onError: () => toast.error("Failed to save weight entry"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...trpc.health.deleteWeightLog.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
|
||||
toast.success("Entry deleted");
|
||||
},
|
||||
onError: () => toast.error("Failed to delete entry"),
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
const kg = parseFloat(weightKg);
|
||||
if (!date || isNaN(kg) || kg <= 0) {
|
||||
toast.error("Please fill in date and a valid weight");
|
||||
return;
|
||||
}
|
||||
addMutation.mutate({ petId, date, weightKg: kg, notes: notes.trim() || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "entry" : "entries"}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Weight
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="w-date" className="text-xs">Date</Label>
|
||||
<Input id="w-date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="w-kg" className="text-xs">Weight (kg)</Label>
|
||||
<Input
|
||||
id="w-kg"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0.001"
|
||||
max="500"
|
||||
placeholder="e.g. 4.2"
|
||||
value={weightKg}
|
||||
onChange={(e) => setWeightKg(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="w-notes" className="text-xs">Notes (optional)</Label>
|
||||
<Input id="w-notes" maxLength={200} placeholder="e.g. after morning walk" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
|
||||
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No weight entries yet</p>
|
||||
)}
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{data.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{formatWeight(entry.weightGrams)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(entry.date)}{entry.notes ? ` · ${entry.notes}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
onClick={() => deleteMutation.mutate({ id: entry.id, petId })}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vet Visits Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function VetVisitsTab({ petId }: { petId: string }) {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [reason, setReason] = useState("");
|
||||
const [vetName, setVetName] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const { data = [], isLoading } = useQuery(trpc.health.listVetVisits.queryOptions({ petId }));
|
||||
|
||||
const addMutation = useMutation({
|
||||
...trpc.health.addVetVisit.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
|
||||
toast.success("Vet visit added");
|
||||
setShowForm(false);
|
||||
setReason("");
|
||||
setVetName("");
|
||||
setNotes("");
|
||||
},
|
||||
onError: () => toast.error("Failed to save vet visit"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...trpc.health.deleteVetVisit.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
|
||||
toast.success("Entry deleted");
|
||||
},
|
||||
onError: () => toast.error("Failed to delete entry"),
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!date || !reason.trim()) {
|
||||
toast.error("Please fill in date and reason");
|
||||
return;
|
||||
}
|
||||
addMutation.mutate({
|
||||
petId,
|
||||
date,
|
||||
reason: reason.trim(),
|
||||
vetName: vetName.trim() || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "visit" : "visits"}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Visit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="v-date" className="text-xs">Date</Label>
|
||||
<Input id="v-date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="v-vet" className="text-xs">Vet / Clinic (optional)</Label>
|
||||
<Input id="v-vet" maxLength={100} placeholder="e.g. Dr. Müller" value={vetName} onChange={(e) => setVetName(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="v-reason" className="text-xs">Reason *</Label>
|
||||
<Input id="v-reason" maxLength={100} placeholder="e.g. Annual checkup" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="v-notes" className="text-xs">Notes (optional)</Label>
|
||||
<Textarea id="v-notes" maxLength={500} placeholder="Diagnosis, treatment, medications…" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
|
||||
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No vet visits yet</p>
|
||||
)}
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{data.map((visit) => (
|
||||
<div key={visit.id} className="flex items-start gap-3 py-2 px-3 rounded-lg hover:bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{visit.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(visit.date)}{visit.vetName ? ` · ${visit.vetName}` : ""}
|
||||
</p>
|
||||
{visit.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{visit.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0 shrink-0"
|
||||
onClick={() => deleteMutation.mutate({ id: visit.id, petId })}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vaccines Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function VaccinesTab({ petId }: { petId: string }) {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [dateGiven, setDateGiven] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [nextDueDate, setNextDueDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const { data = [], isLoading } = useQuery(trpc.health.listVaccines.queryOptions({ petId }));
|
||||
|
||||
const addMutation = useMutation({
|
||||
...trpc.health.addVaccine.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
|
||||
toast.success("Vaccine added");
|
||||
setShowForm(false);
|
||||
setName("");
|
||||
setNextDueDate("");
|
||||
setNotes("");
|
||||
},
|
||||
onError: () => toast.error("Failed to save vaccine"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...trpc.health.deleteVaccine.mutationOptions(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
|
||||
toast.success("Entry deleted");
|
||||
},
|
||||
onError: () => toast.error("Failed to delete entry"),
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim() || !dateGiven) {
|
||||
toast.error("Please fill in vaccine name and date given");
|
||||
return;
|
||||
}
|
||||
addMutation.mutate({
|
||||
petId,
|
||||
name: name.trim(),
|
||||
dateGiven,
|
||||
nextDueDate: nextDueDate || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "vaccine" : "vaccines"}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Vaccine
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vac-name" className="text-xs">Vaccine Name *</Label>
|
||||
<Input id="vac-name" maxLength={100} placeholder="e.g. Rabies, DHPP" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vac-given" className="text-xs">Date Given *</Label>
|
||||
<Input id="vac-given" type="date" value={dateGiven} onChange={(e) => setDateGiven(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vac-due" className="text-xs">Next Due (optional)</Label>
|
||||
<Input id="vac-due" type="date" value={nextDueDate} onChange={(e) => setNextDueDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vac-notes" className="text-xs">Notes (optional)</Label>
|
||||
<Input id="vac-notes" maxLength={200} placeholder="Batch number, clinic…" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
|
||||
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No vaccines recorded yet</p>
|
||||
)}
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{data.map((vac) => {
|
||||
const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate);
|
||||
return (
|
||||
<div key={vac.id} className="flex items-start gap-3 py-2 px-3 rounded-lg hover:bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium">{vac.name}</p>
|
||||
{overdue && (
|
||||
<Badge variant="destructive" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Overdue
|
||||
</Badge>
|
||||
)}
|
||||
{vac.nextDueDate && !overdue && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Due {formatDate(vac.nextDueDate)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Given {formatDate(vac.dateGiven)}{vac.notes ? ` · ${vac.notes}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0 shrink-0"
|
||||
onClick={() => deleteMutation.mutate({ id: vac.id, petId })}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Share Dialog ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ShareDialog({ petId, petName, open, onOpenChange }: {
|
||||
petId: string;
|
||||
petName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const getOrCreateMutation = useMutation({
|
||||
...trpc.health.getOrCreateCard.mutationOptions(),
|
||||
onSuccess: (data) => setToken(data.token),
|
||||
onError: () => toast.error("Failed to generate health card link"),
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
...trpc.health.regenerateCard.mutationOptions(),
|
||||
onSuccess: (data) => {
|
||||
setToken(data.token);
|
||||
toast.success("Link regenerated — old link is now invalid");
|
||||
},
|
||||
onError: () => toast.error("Failed to regenerate link"),
|
||||
});
|
||||
|
||||
const handleOpen = (v: boolean) => {
|
||||
onOpenChange(v);
|
||||
if (v && !token) {
|
||||
getOrCreateMutation.mutate({ petId });
|
||||
}
|
||||
};
|
||||
|
||||
const cardUrl = token ? `${window.location.origin}/health-card/${token}` : null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!cardUrl) return;
|
||||
await navigator.clipboard.writeText(cardUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share {petName}'s Health Card</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anyone with this link can view {petName}'s health records — weight, vaccines, and vet visits. No login required.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{(getOrCreateMutation.isPending) && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardUrl && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={cardUrl} className="text-xs font-mono" />
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} className="shrink-0">
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Regenerate link</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Creates a new link and invalidates the current one. Useful if you want to revoke access.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => regenerateMutation.mutate({ petId })}
|
||||
disabled={regenerateMutation.isPending}
|
||||
className="shrink-0"
|
||||
>
|
||||
{regenerateMutation.isPending
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <RefreshCw className="h-4 w-4" />
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Dashboard ────────────────────────────────────────────────────────────
|
||||
|
||||
export function HealthDashboard({ petId, petName }: HealthDashboardProps) {
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="max-w-[640px] mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-semibold">Health — {petName}</h1>
|
||||
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
|
||||
<Share2 className="h-4 w-4 mr-1.5" />
|
||||
Share Card
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="weight">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="weight">Weight</TabsTrigger>
|
||||
<TabsTrigger value="vet">Vet Visits</TabsTrigger>
|
||||
<TabsTrigger value="vaccines">Vaccines</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="weight">
|
||||
<WeightTab petId={petId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="vet">
|
||||
<VetVisitsTab petId={petId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="vaccines">
|
||||
<VaccinesTab petId={petId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ShareDialog
|
||||
petId={petId}
|
||||
petName={petName}
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { searchRouter } from "@/trpc/routers/search";
|
||||
import { notificationsRouter } from "@/trpc/routers/notifications";
|
||||
import { videosRouter } from "@/trpc/routers/videos";
|
||||
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
|
||||
import { healthRouter } from "@/trpc/routers/health";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -36,6 +37,7 @@ export const appRouter = router({
|
||||
videos: videosRouter,
|
||||
conversations: conversationsRouter,
|
||||
messages: messagesRouter,
|
||||
health: healthRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
async function assertOwner(prisma: PrismaClient, petId: string, userId: string) {
|
||||
const pet = await prisma.pet.findUnique({ where: { id: petId }, select: { ownerId: true } });
|
||||
if (!pet || pet.ownerId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
}
|
||||
|
||||
export const healthRouter = router({
|
||||
// ── Weight Logs ─────────────────────────────────────────────────────────────
|
||||
|
||||
listWeightLogs: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
return ctx.prisma.weightLog.findMany({
|
||||
where: { petId: input.petId },
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
addWeightLog: protectedProcedure
|
||||
.input(z.object({
|
||||
petId: z.string().min(1),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
weightKg: z.number().positive().max(500),
|
||||
notes: z.string().max(200).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
return ctx.prisma.weightLog.create({
|
||||
data: {
|
||||
petId: input.petId,
|
||||
date: new Date(input.date),
|
||||
weightGrams: Math.round(input.weightKg * 1000),
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
deleteWeightLog: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
await ctx.prisma.weightLog.delete({ where: { id: input.id } });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ── Vet Visits ───────────────────────────────────────────────────────────────
|
||||
|
||||
listVetVisits: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
return ctx.prisma.vetVisit.findMany({
|
||||
where: { petId: input.petId },
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
addVetVisit: protectedProcedure
|
||||
.input(z.object({
|
||||
petId: z.string().min(1),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
reason: z.string().min(1).max(100),
|
||||
vetName: z.string().max(100).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
return ctx.prisma.vetVisit.create({
|
||||
data: {
|
||||
petId: input.petId,
|
||||
date: new Date(input.date),
|
||||
reason: input.reason,
|
||||
vetName: input.vetName ?? null,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
deleteVetVisit: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
await ctx.prisma.vetVisit.delete({ where: { id: input.id } });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ── Vaccines ─────────────────────────────────────────────────────────────────
|
||||
|
||||
listVaccines: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
return ctx.prisma.vaccine.findMany({
|
||||
where: { petId: input.petId },
|
||||
orderBy: { dateGiven: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
addVaccine: protectedProcedure
|
||||
.input(z.object({
|
||||
petId: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
dateGiven: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
nextDueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
notes: z.string().max(200).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
return ctx.prisma.vaccine.create({
|
||||
data: {
|
||||
petId: input.petId,
|
||||
name: input.name,
|
||||
dateGiven: new Date(input.dateGiven),
|
||||
nextDueDate: input.nextDueDate ? new Date(input.nextDueDate) : null,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
deleteVaccine: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
await ctx.prisma.vaccine.delete({ where: { id: input.id } });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ── Health Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
getOrCreateCard: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
const card = await ctx.prisma.healthCard.upsert({
|
||||
where: { petId: input.petId },
|
||||
update: {},
|
||||
create: { petId: input.petId },
|
||||
});
|
||||
return { token: card.token };
|
||||
}),
|
||||
|
||||
regenerateCard: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertOwner(ctx.prisma, input.petId, ctx.userId);
|
||||
const newToken = crypto.randomUUID().replace(/-/g, "");
|
||||
const card = await ctx.prisma.healthCard.upsert({
|
||||
where: { petId: input.petId },
|
||||
update: { token: newToken },
|
||||
create: { petId: input.petId, token: newToken },
|
||||
});
|
||||
return { token: card.token };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user