feat(health): emergency vet with OSM search and map

- EmergencyVet model: name, address, phone, website, lat, lng (1:1 to Pet)
- health router: getEmergencyVet, setEmergencyVet, deleteEmergencyVet
- EmergencyVetSection: Nominatim search (debounced 500ms, OSM attribution),
  result dropdown auto-fills address + coordinates, manual override for all fields
- OSM iframe map (no API key) with "In Karte öffnen" link; shown in edit
  mode as coordinate preview and in view mode after save
- Health card (public): red-accented emergency vet block with phone link,
  website link and OSM map; map hidden on print (print:hidden)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-15 17:39:46 +02:00
1 parent c9f385a9fa
commit d17d71e55a
5 files changed
+505 -4

No files matched your search

+66
View File
@@ -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
</div>
</div>
{/* Emergency vet */}
{pet.emergencyVet && (
<div className="bg-card rounded-xl border border-red-200 dark:border-red-900 p-5 space-y-3">
<h2 className="text-base font-semibold flex items-center gap-2">
<span className="w-1 h-5 rounded-full bg-red-500 inline-block" />
Notfalltierarzt
</h2>
<div>
<p className="text-sm font-medium">{pet.emergencyVet.name}</p>
{pet.emergencyVet.address && (
<p className="text-xs text-muted-foreground flex items-start gap-1 mt-0.5">
<MapPin className="h-3 w-3 mt-0.5 shrink-0" />
{pet.emergencyVet.address}
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
{pet.emergencyVet.phone && (
<a
href={`tel:${pet.emergencyVet.phone}`}
className="flex items-center gap-1.5 text-sm font-medium text-orange-500 hover:underline"
>
<Phone className="h-4 w-4" />
{pet.emergencyVet.phone}
</a>
)}
{pet.emergencyVet.website && (
<a
href={pet.emergencyVet.website.startsWith("http") ? pet.emergencyVet.website : `https://${pet.emergencyVet.website}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Globe className="h-4 w-4" />
Website
</a>
)}
</div>
{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 (
<div className="rounded-lg overflow-hidden border relative print:hidden">
<iframe
src={`https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lng}`}
className="w-full h-44 block"
loading="lazy"
title="Tierarzt Standort"
/>
<a
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`}
target="_blank"
rel="noopener noreferrer"
className="absolute bottom-2 right-2 text-xs bg-background/90 hover:bg-background border rounded-md px-2 py-1 shadow-sm"
>
In Karte öffnen
</a>
</div>
);
})()}
</div>
)}
{/* Vaccines */}
{pet.vaccines.length > 0 && (
<div className="bg-card rounded-xl border p-5 space-y-3">
@@ -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 (
<div className="rounded-xl overflow-hidden border relative">
<iframe
src={src}
className="w-full h-44 block"
loading="lazy"
title="Tierarzt Standort"
/>
<a
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="absolute bottom-2 right-2 flex items-center gap-1 text-xs bg-background/90 hover:bg-background border rounded-md px-2 py-1 shadow-sm transition-colors"
>
<ExternalLink className="h-3 w-3" />
In Karte öffnen
</a>
<p className="text-[10px] text-muted-foreground px-2 py-1 bg-background/60">
© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="underline">OpenStreetMap</a> contributors
</p>
</div>
);
}
// ── Search hook ───────────────────────────────────────────────────────────────
function useNominatimSearch(query: string) {
const [results, setResults] = useState<NominatimResult[]>([]);
const [loading, setLoading] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="mt-8 space-y-3">
{/* Section header */}
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold flex items-center gap-2">
<span className="w-1 h-4 rounded-full bg-red-500 inline-block" />
Notfalltierarzt
</h2>
{vet && !editing && (
<div className="flex gap-1">
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={openEdit}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm" variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => deleteMutation.mutate({ petId })}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
{!vet && !editing && (
<Button size="sm" variant="outline" onClick={openEdit}>
<MapPin className="h-3.5 w-3.5 mr-1" />
Tierarzt hinterlegen
</Button>
)}
</div>
{/* ── View mode ─────────────────────────────────────── */}
{vet && !editing && (
<div className="rounded-xl border bg-card p-4 space-y-3">
<div>
<p className="font-medium text-sm">{vet.name}</p>
{vet.address && (
<p className="text-xs text-muted-foreground flex items-start gap-1 mt-0.5">
<MapPin className="h-3 w-3 mt-0.5 shrink-0" />
{vet.address}
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
{vet.phone && (
<a
href={`tel:${vet.phone}`}
className="flex items-center gap-1.5 text-xs text-orange-500 hover:underline font-medium"
>
<Phone className="h-3.5 w-3.5" />
{vet.phone}
</a>
)}
{vet.website && (
<a
href={vet.website.startsWith("http") ? vet.website : `https://${vet.website}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<Globe className="h-3.5 w-3.5" />
Website
</a>
)}
</div>
{vet.lat != null && vet.lng != null && (
<OsmMap lat={vet.lat} lng={vet.lng} />
)}
</div>
)}
{/* ── Edit / Search mode ────────────────────────────── */}
{editing && (
<div className="rounded-xl border bg-card p-4 space-y-4">
{/* Nominatim search */}
<div className="space-y-1.5 relative">
<Label className="text-xs">Tierarzt suchen</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
placeholder="z.B. Tierarzt Musterstraße München…"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setShowResults(true); }}
onFocus={() => setShowResults(true)}
className="pl-8 text-sm"
/>
{searching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
)}
</div>
{/* Results dropdown */}
{showResults && results.length > 0 && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-popover border rounded-lg shadow-lg overflow-hidden">
{results.map((r) => (
<button
key={r.place_id}
type="button"
className="w-full text-left px-3 py-2 text-xs hover:bg-muted transition-colors border-b last:border-0"
onClick={() => pickResult(r)}
>
<span className="font-medium">{r.display_name.split(",")[0]}</span>
<span className="text-muted-foreground block truncate">
{r.display_name.split(",").slice(1).join(",").trim()}
</span>
</button>
))}
<button
type="button"
className="w-full text-left px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted"
onClick={() => setShowResults(false)}
>
<X className="h-3 w-3 inline mr-1" />
Schließen
</button>
</div>
)}
</div>
{/* Manual fields */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="ev-name" className="text-xs">Name *</Label>
<Input
id="ev-name"
placeholder="Dr. Muster Tierarztpraxis"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
maxLength={150}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ev-address" className="text-xs">Adresse</Label>
<Input
id="ev-address"
placeholder="Musterstraße 1, 80333 München"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
maxLength={300}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ev-phone" className="text-xs">Telefon</Label>
<Input
id="ev-phone"
type="tel"
placeholder="+49 89 123456"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
maxLength={30}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ev-web" className="text-xs">Website</Label>
<Input
id="ev-web"
type="url"
placeholder="www.tierarzt.de"
value={form.website}
onChange={(e) => setForm((f) => ({ ...f, website: e.target.value }))}
maxLength={200}
/>
</div>
</div>
</div>
{/* Map preview if coords set */}
{form.lat != null && form.lng != null && (
<OsmMap lat={form.lat} lng={form.lng} />
)}
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>Abbrechen</Button>
<Button size="sm" onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Speichern"}
</Button>
</div>
</div>
)}
{/* Empty state */}
{!vet && !editing && (
<p className="text-xs text-muted-foreground">
Noch kein Notfalltierarzt hinterlegt. Im Notfall weiss jeder sofort wohin.
</p>
)}
</div>
);
}
@@ -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) {
</TabsContent>
</Tabs>
<EmergencyVetSection petId={petId} />
<ShareDialog
petId={petId}
petName={petName}
+37
View File
@@ -132,6 +132,43 @@ export const healthRouter = router({
return { success: true };
}),
// ── Emergency Vet ────────────────────────────────────────────────────────────
getEmergencyVet: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
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