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:
1 parent
c9f385a9fa
commit
d17d71e55a
5 files changed
+505
-4
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user