diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8844a6e..72d492a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -72,10 +72,11 @@ model Pet {
sentMessages Message[] @relation("MessageSender")
// Phase 7 back-relations
- weightLogs WeightLog[]
- vetVisits VetVisit[]
- vaccines Vaccine[]
- healthCard HealthCard?
+ weightLogs WeightLog[]
+ vetVisits VetVisit[]
+ vaccines Vaccine[]
+ healthCard HealthCard?
+ emergencyVet EmergencyVet?
@@index([ownerId])
}
@@ -389,6 +390,20 @@ model Vaccine {
@@index([petId])
}
+model EmergencyVet {
+ id String @id @default(cuid())
+ pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
+ petId String @unique
+ name String @db.VarChar(150)
+ address String? @db.VarChar(300)
+ phone String? @db.VarChar(30)
+ website String? @db.VarChar(200)
+ lat Float?
+ lng Float?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
model HealthCard {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx
index 2b77a13..8f1670d 100644
--- a/src/app/health-card/[token]/page.tsx
+++ b/src/app/health-card/[token]/page.tsx
@@ -8,6 +8,7 @@ import { getAvatarUrl } from "@/lib/avatar-url";
import { PrintButton } from "@/components/health/PrintButton";
import { AutoPrint } from "@/components/health/AutoPrint";
import { WeightChart } from "@/components/health/WeightChart";
+import { Phone, Globe, MapPin } from "lucide-react";
interface PageProps {
params: Promise<{ token: string }>;
@@ -46,6 +47,7 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
weightLogs: { orderBy: { date: "desc" }, take: 20 },
vetVisits: { orderBy: { date: "desc" }, take: 10 },
vaccines: { orderBy: { dateGiven: "desc" } },
+ emergencyVet: true,
},
},
},
@@ -115,6 +117,70 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
+ {/* Emergency vet */}
+ {pet.emergencyVet && (
+
+
+
+ Notfalltierarzt
+
+
+
{pet.emergencyVet.name}
+ {pet.emergencyVet.address && (
+
+
+ {pet.emergencyVet.address}
+
+ )}
+
+
+ {pet.emergencyVet.lat != null && pet.emergencyVet.lng != null && (() => {
+ const { lat, lng } = pet.emergencyVet as { lat: number; lng: number };
+ const delta = 0.012;
+ const bbox = `${lng - delta},${lat - delta},${lng + delta},${lat + delta}`;
+ return (
+
+ );
+ })()}
+
+ )}
+
{/* Vaccines */}
{pet.vaccines.length > 0 && (
diff --git a/src/components/health/EmergencyVetSection.tsx b/src/components/health/EmergencyVetSection.tsx
new file mode 100644
index 0000000..c46097c
--- /dev/null
+++ b/src/components/health/EmergencyVetSection.tsx
@@ -0,0 +1,380 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ Phone, Globe, MapPin, Pencil, Trash2, Search, Loader2, X, ExternalLink,
+} from "lucide-react";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+// ── Nominatim types ───────────────────────────────────────────────────────────
+
+interface NominatimResult {
+ place_id: number;
+ display_name: string;
+ lat: string;
+ lon: string;
+ address?: {
+ road?: string;
+ house_number?: string;
+ postcode?: string;
+ city?: string;
+ town?: string;
+ village?: string;
+ country?: string;
+ };
+}
+
+function buildAddress(r: NominatimResult): string {
+ const a = r.address ?? {};
+ const street = [a.road, a.house_number].filter(Boolean).join(" ");
+ const city = a.city ?? a.town ?? a.village ?? "";
+ const postcode = a.postcode ?? "";
+ return [street, [postcode, city].filter(Boolean).join(" "), a.country]
+ .filter(Boolean)
+ .join(", ");
+}
+
+// ── OSM Map iframe ────────────────────────────────────────────────────────────
+
+function OsmMap({ lat, lng }: { lat: number; lng: number }) {
+ const delta = 0.012;
+ const bbox = `${lng - delta},${lat - delta},${lng + delta},${lat + delta}`;
+ const src = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lng}`;
+ const mapsUrl = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`;
+
+ return (
+
+ );
+}
+
+// ── Search hook ───────────────────────────────────────────────────────────────
+
+function useNominatimSearch(query: string) {
+ const [results, setResults] = useState
([]);
+ const [loading, setLoading] = useState(false);
+ const timerRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ if (query.trim().length < 3) { setResults([]); return; }
+
+ timerRef.current = setTimeout(async () => {
+ setLoading(true);
+ try {
+ const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&addressdetails=1`;
+ const res = await fetch(url, {
+ headers: { "Accept-Language": "de" },
+ });
+ const data: NominatimResult[] = await res.json();
+ setResults(data);
+ } catch {
+ setResults([]);
+ } finally {
+ setLoading(false);
+ }
+ }, 500);
+
+ return () => { if (timerRef.current) clearTimeout(timerRef.current); };
+ }, [query]);
+
+ return { results, loading };
+}
+
+// ── Main component ────────────────────────────────────────────────────────────
+
+interface Props { petId: string }
+
+export function EmergencyVetSection({ petId }: Props) {
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+
+ const { data: vet, isLoading } = useQuery({
+ ...trpc.health.getEmergencyVet.queryOptions({ petId }),
+ });
+
+ const saveMutation = useMutation({
+ ...trpc.health.setEmergencyVet.mutationOptions(),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: trpc.health.getEmergencyVet.queryKey({ petId }) });
+ toast.success("Tierarzt gespeichert");
+ setEditing(false);
+ },
+ onError: () => toast.error("Speichern fehlgeschlagen"),
+ });
+
+ const deleteMutation = useMutation({
+ ...trpc.health.deleteEmergencyVet.mutationOptions(),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: trpc.health.getEmergencyVet.queryKey({ petId }) });
+ toast.success("Tierarzt entfernt");
+ },
+ onError: () => toast.error("Löschen fehlgeschlagen"),
+ });
+
+ // Edit form state
+ const [editing, setEditing] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [form, setForm] = useState({
+ name: "", address: "", phone: "", website: "", lat: null as number | null, lng: null as number | null,
+ });
+
+ const { results, loading: searching } = useNominatimSearch(searchQuery);
+ const [showResults, setShowResults] = useState(false);
+
+ // Open edit form pre-filled with existing data
+ const openEdit = () => {
+ setForm({
+ name: vet?.name ?? "",
+ address: vet?.address ?? "",
+ phone: vet?.phone ?? "",
+ website: vet?.website ?? "",
+ lat: vet?.lat ?? null,
+ lng: vet?.lng ?? null,
+ });
+ setSearchQuery("");
+ setEditing(true);
+ };
+
+ const pickResult = (r: NominatimResult) => {
+ const addr = buildAddress(r);
+ // Use display_name parts as vet name if not already filled, else keep
+ const namePart = r.display_name.split(",")[0] ?? r.display_name;
+ setForm((f) => ({
+ ...f,
+ name: f.name || namePart,
+ address: addr,
+ lat: parseFloat(r.lat),
+ lng: parseFloat(r.lon),
+ }));
+ setSearchQuery(addr);
+ setShowResults(false);
+ };
+
+ const handleSave = () => {
+ if (!form.name.trim()) { toast.error("Name ist erforderlich"); return; }
+ saveMutation.mutate({
+ petId,
+ name: form.name.trim(),
+ address: form.address || null,
+ phone: form.phone || null,
+ website: form.website || null,
+ lat: form.lat,
+ lng: form.lng,
+ });
+ };
+
+ if (isLoading) return null;
+
+ return (
+
+ {/* Section header */}
+
+
+
+ Notfalltierarzt
+
+ {vet && !editing && (
+
+
+
+
+ )}
+ {!vet && !editing && (
+
+ )}
+
+
+ {/* ── View mode ─────────────────────────────────────── */}
+ {vet && !editing && (
+
+
+
{vet.name}
+ {vet.address && (
+
+
+ {vet.address}
+
+ )}
+
+
+ {vet.lat != null && vet.lng != null && (
+
+ )}
+
+ )}
+
+ {/* ── Edit / Search mode ────────────────────────────── */}
+ {editing && (
+
+ {/* Nominatim search */}
+
+
+
+
+ { setSearchQuery(e.target.value); setShowResults(true); }}
+ onFocus={() => setShowResults(true)}
+ className="pl-8 text-sm"
+ />
+ {searching && (
+
+ )}
+
+ {/* Results dropdown */}
+ {showResults && results.length > 0 && (
+
+ {results.map((r) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* Manual fields */}
+
+
+
+ setForm((f) => ({ ...f, name: e.target.value }))}
+ maxLength={150}
+ />
+
+
+
+ setForm((f) => ({ ...f, address: e.target.value }))}
+ maxLength={300}
+ />
+
+
+
+
+ {/* Map preview if coords set */}
+ {form.lat != null && form.lng != null && (
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {/* Empty state */}
+ {!vet && !editing && (
+
+ Noch kein Notfalltierarzt hinterlegt. Im Notfall weiss jeder sofort wohin.
+
+ )}
+
+ );
+}
diff --git a/src/components/health/HealthDashboard.tsx b/src/components/health/HealthDashboard.tsx
index aef0c4f..bc7b186 100644
--- a/src/components/health/HealthDashboard.tsx
+++ b/src/components/health/HealthDashboard.tsx
@@ -27,6 +27,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { WeightChart } from "@/components/health/WeightChart";
+import { EmergencyVetSection } from "@/components/health/EmergencyVetSection";
interface HealthDashboardProps {
petId: string;
@@ -625,6 +626,8 @@ export function HealthDashboard({ petId, petName }: HealthDashboardProps) {
+
+
{
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ return ctx.prisma.emergencyVet.findUnique({ where: { petId: input.petId } });
+ }),
+
+ setEmergencyVet: protectedProcedure
+ .input(z.object({
+ petId: z.string().min(1),
+ name: z.string().min(1).max(150),
+ address: z.string().max(300).optional().nullable(),
+ phone: z.string().max(30).optional().nullable(),
+ website: z.string().max(200).optional().nullable(),
+ lat: z.number().optional().nullable(),
+ lng: z.number().optional().nullable(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ const { petId, ...data } = input;
+ return ctx.prisma.emergencyVet.upsert({
+ where: { petId },
+ update: data,
+ create: { petId, ...data },
+ });
+ }),
+
+ deleteEmergencyVet: protectedProcedure
+ .input(z.object({ petId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ await ctx.prisma.emergencyVet.deleteMany({ where: { petId: input.petId } });
+ return { success: true };
+ }),
+
// ── Health Card ──────────────────────────────────────────────────────────────
getOrCreateCard: protectedProcedure