Add vaccines, teeth tracking, child management, and WHO growth percentiles

- Add tooth router and vaccine router with full CRUD operations
- Implement vaccine form and list components with edit/delete functionality
- Connect denture visualization to database for persistent tooth tracking
- Add child edit dialog and delete functionality with cascade deletion
- Implement WHO growth percentile calculations for weight and height
- Update dashboard to display real data for measurements, vaccines, and teeth
- Add dialog, alert-dialog, and tooltip UI components
- Install @radix-ui/react-dialog dependency
This commit is contained in:
Philip
2026-02-16 21:16:56 +01:00
parent 84a2b3bf0d
commit e6ad08c4a9
19 changed files with 3095 additions and 81 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { format, differenceInMonths } from "date-fns";
import { de } from "date-fns/locale";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -25,14 +26,39 @@ import type { AppRouter } from "@/server/api/root";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp } from "lucide-react";
import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp, Trash2, List, InfoIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { DentureVisualization } from "@/components/child/denture-visualization";
import { VaccineForm } from "@/components/child/vaccine-form";
import { VaccineList } from "@/components/child/vaccine-list";
import { ChildEditDialog } from "@/components/child/child-edit-dialog";
import { calculatePercentile } from "@/lib/percentiles";
import {
Tooltip as UITooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const measurementFormSchema = z.object({
date: z.date({
required_error: "Bitte wähle ein Datum",
}),
}).refine(
(date) => date <= new Date(),
"Messungen können nicht für zukünftige Daten hinzugefügt werden"
),
weightKg: z.string().refine((val) => !isNaN(Number(val)) && Number(val) > 0, {
message: "Bitte gib ein gültiges Gewicht ein",
}),
@@ -48,6 +74,7 @@ interface ChildDetailContentProps {
}
export function ChildDetailContent({ childId }: ChildDetailContentProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [date, setDate] = useState<Date | undefined>(new Date());
const [expandedSections, setExpandedSections] = useState({
@@ -56,6 +83,35 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
vaccinations: false,
toothing: false,
});
const [showMeasurementsList, setShowMeasurementsList] = useState(false);
const [showVaccineForm, setShowVaccineForm] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDeleteChild = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/trpc/child.delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ json: { id: childId } }),
});
if (!response.ok) throw new Error('Failed to delete');
toast.success("Kind gelöscht", {
description: "Das Kind wurde erfolgreich gelöscht."
});
router.push('/app');
} catch {
toast.error("Fehler", {
description: "Das Kind konnte nicht gelöscht werden."
});
} finally {
setIsLoading(false);
setShowDeleteDialog(false);
}
};
// Define error handler within component scope
const handleAddMeasurement = (err: TRPCClientErrorLike<AppRouter>) => {
@@ -74,6 +130,18 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{ enabled: !!childId }
);
// Fetch teeth status
const { data: teethStatus, refetch: refetchTeeth } = trpc.tooth.getByChildId.useQuery(
{ childId },
{ enabled: !!childId }
);
// Fetch vaccines
const { data: vaccines, refetch: refetchVaccines } = trpc.vaccine.getByChildId.useQuery(
{ childId },
{ enabled: !!childId }
);
// Add measurement mutation
const addMeasurement = trpc.measurement.add.useMutation({
onSuccess: () => {
@@ -88,6 +156,21 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
onError: handleAddMeasurement,
});
// Add delete measurement mutation
const deleteMeasurement = trpc.measurement.delete.useMutation({
onSuccess: () => {
toast.success("Messung gelöscht", {
description: "Die Messung wurde erfolgreich gelöscht."
});
refetchMeasurements();
},
onError: (err) => {
toast.error("Fehler", {
description: err.message
});
},
});
// Form setup
const form = useForm<MeasurementFormValues>({
resolver: zodResolver(measurementFormSchema),
@@ -103,6 +186,16 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
// Create a new date at the start of the selected day in local timezone
const selectedDate = date || new Date();
// Check if the selected date is in the future
if (selectedDate > new Date()) {
toast.error("Fehler", {
description: "Messungen können nicht für zukünftige Daten hinzugefügt werden"
});
setIsLoading(false);
return;
}
const localDate = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
@@ -150,6 +243,27 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
const birthDate = new Date(child.birthDate);
const ageInMonths = differenceInMonths(new Date(), birthDate);
const gender = (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown";
const latestMeasurement = measurements?.[0];
const weightPercentile = latestMeasurement?.weightKg
? calculatePercentile(latestMeasurement.weightKg, ageInMonths, gender, "weight")
: null;
const heightPercentile = latestMeasurement?.heightCm
? calculatePercentile(latestMeasurement.heightCm, ageInMonths, gender, "height")
: null;
const getDevelopmentStatus = () => {
if (!weightPercentile && !heightPercentile) return "Keine Daten";
if ((weightPercentile && weightPercentile.percentile < 3) || (heightPercentile && heightPercentile.percentile < 3)) {
return "Achtung";
}
if ((weightPercentile && weightPercentile.percentile > 97) || (heightPercentile && heightPercentile.percentile > 97)) {
return "Achtung";
}
return "Normal";
};
// Prepare data for charts
const measurementData = measurements
?.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
@@ -163,15 +277,48 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
<div className="space-y-6">
<Card className="overflow-hidden">
<div className="bg-gradient-to-r from-rose-100 to-rose-200 p-6">
<div className="flex items-center gap-4">
<div className="bg-white/80 p-3 rounded-full shadow-sm">
<Baby className="h-8 w-8 text-rose-600" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-white/80 p-3 rounded-full shadow-sm">
<Baby className="h-8 w-8 text-rose-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-rose-900">{child.name}</h2>
<p className="text-rose-700">
{ageInMonths} Monate {format(new Date(child.birthDate), "PPP", { locale: de })}
</p>
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-rose-900">{child.name}</h2>
<p className="text-rose-700">
{ageInMonths} Monate {format(new Date(child.birthDate), "PPP", { locale: de })}
</p>
<div className="flex items-center gap-2">
<ChildEditDialog child={child} onSuccess={() => {
trpc.useUtils().child.getById.invalidate({ id: childId });
}} />
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4 mr-1" />
Löschen
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Kind löschen</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie {child.name} wirklich löschen? Alle zugehörigen Daten (Messungen, Impfungen, Zahnstatus) werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteChild}
className="bg-red-600 text-white hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? "Wird gelöscht..." : "Löschen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
@@ -196,7 +343,15 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
<Stethoscope className="h-5 w-5 text-rose-500" />
<h3 className="font-medium text-gray-700">Entwicklung</h3>
</div>
<p className="text-lg">Normal</p>
<p className={`text-lg ${getDevelopmentStatus() === 'Achtung' ? 'text-amber-600' : 'text-gray-900'}`}>
{getDevelopmentStatus()}
</p>
{weightPercentile && (
<p className="text-sm text-gray-500">Gewicht: {weightPercentile.percentile}. Perzentil</p>
)}
{heightPercentile && (
<p className="text-sm text-gray-500">Größe: {heightPercentile.percentile}. Perzentil</p>
)}
</div>
</div>
</CardContent>
@@ -391,6 +546,59 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
</ResponsiveContainer>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Liste der Messungen</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMeasurementsList(!showMeasurementsList)}
className="flex items-center gap-2"
>
<List className="h-4 w-4" />
<span>{showMeasurementsList ? "Ausblenden" : "Anzeigen"}</span>
</Button>
</div>
{showMeasurementsList && (
<div className="space-y-2">
{measurements.map((measurement) => (
<div key={measurement.id} className="flex items-center justify-between p-4 bg-white rounded-lg border">
<div>
<p className="font-medium">{format(new Date(measurement.date), "PPP", { locale: de })}</p>
<p className="text-sm text-gray-500">
Gewicht: {measurement.weightKg} kg Größe: {measurement.heightCm} cm
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Messung löschen</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie diese Messung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMeasurement.mutate({ id: measurement.id })}
className="bg-red-600 text-white hover:bg-red-700"
>
Löschen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
</div>
) : (
<p className="text-muted-foreground">Noch keine Messungen aufgezeichnet.</p>
@@ -414,32 +622,49 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{expandedSections.vaccinations && (
<div className="p-4 space-y-4">
<div className="flex justify-end">
<Button size="sm" className="bg-rose-600 text-white hover:bg-rose-700">
<Plus className="h-4 w-4 mr-2" />
Impfung hinzufügen
</Button>
</div>
<div className="space-y-4">
<p className="text-muted-foreground">Noch keine Impfungen aufgezeichnet.</p>
<div className="bg-white rounded-md p-4 border">
<h3 className="text-lg font-medium mb-2">Empfohlene Impfungen</h3>
<ul className="space-y-2">
<li className="flex items-center justify-between">
<span>6-fach Impfung</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Pneumokokken</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Rotaviren</span>
<span className="text-sm text-gray-500">6 Wochen</span>
</li>
</ul>
{!showVaccineForm ? (
<div className="flex justify-end">
<Button
size="sm"
className="bg-rose-600 text-white hover:bg-rose-700"
onClick={() => setShowVaccineForm(true)}
>
<Plus className="h-4 w-4 mr-2" />
Impfung hinzufügen
</Button>
</div>
) : (
<VaccineForm
childId={childId}
onSuccess={() => {
setShowVaccineForm(false);
refetchVaccines();
}}
onCancel={() => setShowVaccineForm(false)}
/>
)}
<VaccineList
vaccines={vaccines || []}
onRefetch={() => refetchVaccines()}
/>
<div className="bg-white rounded-md p-4 border mt-4">
<h3 className="text-lg font-medium mb-2">Empfohlene Impfungen</h3>
<ul className="space-y-2">
<li className="flex items-center justify-between">
<span>6-fach Impfung</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Pneumokokken</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Rotaviren</span>
<span className="text-sm text-gray-500">6 Wochen</span>
</li>
</ul>
</div>
</div>
)}
@@ -460,15 +685,7 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{expandedSections.toothing && (
<div className="p-4 space-y-4">
<div className="flex justify-end">
<Button size="sm" className="bg-rose-600 text-white hover:bg-rose-700">
<Plus className="h-4 w-4 mr-2" />
Zahn hinzufügen
</Button>
</div>
<div className="space-y-4">
<p className="text-muted-foreground">Noch keine Zähne aufgezeichnet.</p>
<div className="bg-white rounded-md p-4 border">
<h3 className="text-lg font-medium mb-2">Zahnungsplan</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -508,6 +725,31 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
</div>
</div>
</div>
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-medium">Zahnstatus</h3>
<TooltipProvider>
<UITooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">about:blank#blocked
<InfoIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Klicken Sie auf einen Zahn in der Visualisierung oder wählen Sie einen Zahn aus dem Dropdown-Menü, um den Durchbruch zu markieren.</p>
<p>Rote Zähne sind bereits durchgebrochen.</p>
</TooltipContent>
</UITooltip>
</TooltipProvider>
</div>
<DentureVisualization
childId={childId}
initialTeeth={teethStatus || []}
onToothSaved={() => refetchTeeth()}
showTitle={false}
/>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CalendarIcon, Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
const childFormSchema = z.object({
name: z.string().min(1, "Name ist erforderlich"),
birthDate: z.date({
required_error: "Bitte wähle ein Geburtsdatum",
}),
gender: z.enum(["male", "female", "diverse", "unknown"]),
});
type ChildFormValues = z.infer<typeof childFormSchema>;
interface Child {
id: string;
name: string;
birthDate: Date | string;
gender: string | null;
}
interface ChildEditDialogProps {
child: Child;
onSuccess: () => void;
}
export function ChildEditDialog({ child, onSuccess }: ChildEditDialogProps) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState<Date>(new Date(child.birthDate));
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const form = useForm<ChildFormValues>({
resolver: zodResolver(childFormSchema),
defaultValues: {
name: child.name,
birthDate: new Date(child.birthDate),
gender: (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown",
},
});
const onSubmit = form.handleSubmit(async (values: ChildFormValues) => {
setIsLoading(true);
try {
const response = await fetch('/api/trpc/child.update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
json: {
id: child.id,
name: values.name,
birthDate: date.toISOString(),
gender: values.gender,
}
}),
});
if (!response.ok) throw new Error('Failed to save');
toast.success("Kind aktualisiert", {
description: "Die Daten wurden erfolgreich gespeichert."
});
setOpen(false);
onSuccess();
router.refresh();
} catch {
toast.error("Fehler", {
description: "Die Daten konnten nicht gespeichert werden."
});
} finally {
setIsLoading(false);
}
});
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
className="text-gray-600 hover:text-gray-800"
>
<Pencil className="h-4 w-4 mr-1" />
Bearbeiten
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle>
<DialogDescription>
Bearbeiten Sie die Daten von {child.name}
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-gray-700">
Name *
</label>
<Input
id="name"
{...form.register("name")}
disabled={isLoading}
/>
{form.formState.errors.name && (
<p className="text-sm text-red-600">{form.formState.errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Geburtsdatum *
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP", { locale: de }) : <span>Datum auswählen</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(d) => d && setDate(d)}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Geschlecht
</label>
<Select
value={form.watch("gender")}
onValueChange={(value) => form.setValue("gender", value as "male" | "female" | "diverse" | "unknown")}
>
<SelectTrigger>
<SelectValue placeholder="Geschlecht auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">Männlich</SelectItem>
<SelectItem value="female">Weiblich</SelectItem>
<SelectItem value="diverse">Divers</SelectItem>
<SelectItem value="unknown">Unbekannt</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Abbrechen
</Button>
<Button
type="submit"
disabled={isLoading}
className="bg-rose-600 text-white hover:bg-rose-700"
>
{isLoading ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,416 @@
"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { CalendarIcon, InfoIcon, Plus } from "lucide-react";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { trpc } from "@/utils/trpc";
const UPPER_TEETH = [
{ id: "U8", name: "Oben rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 },
{ id: "U7", name: "Oben rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 },
{ id: "U6", name: "Oben rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 },
{ id: "U5", name: "Oben rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 },
{ id: "U4", name: "Oben rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 },
{ id: "U3", name: "Oben rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 },
{ id: "U2", name: "Oben rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 },
{ id: "U1", name: "Oben rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 },
{ id: "U1L", name: "Oben links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 },
{ id: "U2L", name: "Oben links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 },
{ id: "U3L", name: "Oben links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 },
{ id: "U4L", name: "Oben links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 },
{ id: "U5L", name: "Oben links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 },
{ id: "U6L", name: "Oben links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 },
{ id: "U7L", name: "Oben links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 },
{ id: "U8L", name: "Oben links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 },
];
const LOWER_TEETH = [
{ id: "L8", name: "Unten rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 },
{ id: "L7", name: "Unten rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 },
{ id: "L6", name: "Unten rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 },
{ id: "L5", name: "Unten rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 },
{ id: "L4", name: "Unten rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 },
{ id: "L3", name: "Unten rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 },
{ id: "L2", name: "Unten rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 },
{ id: "L1", name: "Unten rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 },
{ id: "L1L", name: "Unten links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 },
{ id: "L2L", name: "Unten links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 },
{ id: "L3L", name: "Unten links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 },
{ id: "L4L", name: "Unten links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 },
{ id: "L5L", name: "Unten links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 },
{ id: "L6L", name: "Unten links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 },
{ id: "L7L", name: "Unten links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 },
{ id: "L8L", name: "Unten links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 },
];
interface Tooth {
id: string;
name: string;
x: number;
y: number;
width: number;
height: number;
curve: number;
erupted?: boolean;
eruptionDate?: Date;
dbId?: string;
}
interface ToothStatusFromDB {
id: string;
toothLabel: string;
date: Date | string;
status: string;
}
interface DentureVisualizationProps {
childId: string;
initialTeeth?: ToothStatusFromDB[];
onToothSaved?: () => void;
showTitle?: boolean;
}
export function DentureVisualization({
childId,
initialTeeth = [],
onToothSaved,
showTitle = false
}: DentureVisualizationProps) {
const [upperTeeth, setUpperTeeth] = useState<Tooth[]>([]);
const [lowerTeeth, setLowerTeeth] = useState<Tooth[]>([]);
const [selectedTooth, setSelectedTooth] = useState<Tooth | null>(null);
const [eruptionDate, setEruptionDate] = useState<Date | undefined>(new Date());
const [hoveredTooth, setHoveredTooth] = useState<Tooth | null>(null);
const addTooth = trpc.tooth.add.useMutation({
onSuccess: () => {
toast.success("Zahn hinzugefügt", {
description: selectedTooth ? `${selectedTooth.name} wurde am ${format(eruptionDate!, "PPP", { locale: de })} als durchgebrochen markiert.` : ""
});
setSelectedTooth(null);
onToothSaved?.();
},
onError: (error) => {
toast.error("Fehler", {
description: error.message || "Der Zahn konnte nicht gespeichert werden."
});
},
});
useEffect(() => {
const teethMap = new Map<string, ToothStatusFromDB>();
initialTeeth.forEach(t => teethMap.set(t.toothLabel, t));
const mapTeeth = (teeth: typeof UPPER_TEETH): Tooth[] =>
teeth.map(tooth => {
const dbTooth = teethMap.get(tooth.id);
return {
...tooth,
erupted: !!dbTooth,
eruptionDate: dbTooth ? new Date(dbTooth.date) : undefined,
dbId: dbTooth?.id,
};
});
setUpperTeeth(mapTeeth(UPPER_TEETH));
setLowerTeeth(mapTeeth(LOWER_TEETH));
}, [initialTeeth]);
const handleToothClick = (tooth: Tooth) => {
setSelectedTooth(tooth);
if (tooth.eruptionDate) {
setEruptionDate(tooth.eruptionDate);
} else {
setEruptionDate(new Date());
}
};
const handleEruptionDateSelect = (date: Date | undefined) => {
setEruptionDate(date);
};
const handleSaveEruption = () => {
if (!selectedTooth || !eruptionDate) return;
const localDate = new Date(
eruptionDate.getFullYear(),
eruptionDate.getMonth(),
eruptionDate.getDate(),
12, 0, 0, 0
);
addTooth.mutate({
childId,
toothLabel: selectedTooth.id,
date: localDate.toISOString(),
status: "durchgebrochen",
});
const updatedUpperTeeth = upperTeeth.map(t =>
t.id === selectedTooth.id
? { ...t, erupted: true, eruptionDate: eruptionDate }
: t
);
const updatedLowerTeeth = lowerTeeth.map(t =>
t.id === selectedTooth.id
? { ...t, erupted: true, eruptionDate: eruptionDate }
: t
);
setUpperTeeth(updatedUpperTeeth);
setLowerTeeth(updatedLowerTeeth);
};
const getToothPath = (tooth: Tooth, isUpper: boolean) => {
const { x, y, width, height, curve } = tooth;
const curveHeight = height * curve;
const curveDirection = isUpper ? -1 : 1;
const cp1x = x + width * 0.25;
const cp1y = y + (isUpper ? height : 0);
const cp2x = x + width * 0.75;
const cp2y = y + (isUpper ? height : 0);
const startX = x;
const startY = y + (isUpper ? 0 : height);
const endX = x + width;
const endY = y + (isUpper ? 0 : height);
return `M ${startX} ${startY}
C ${cp1x} ${cp1y + curveHeight * curveDirection * 0.5},
${cp2x} ${cp2y + curveHeight * curveDirection * 0.5},
${endX} ${endY}`;
};
const handleToothSelect = (toothId: string) => {
const tooth = [...upperTeeth, ...lowerTeeth].find(t => t.id === toothId);
if (tooth) {
handleToothClick(tooth);
}
};
return (
<TooltipProvider>
<div className="space-y-4">
{showTitle && (
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Zahnstatus</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<InfoIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Klicken Sie auf einen Zahn, um den Durchbruch zu markieren.</p>
<p>Rote Zähne sind bereits durchgebrochen.</p>
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium">Zahn auswählen:</label>
<Select onValueChange={handleToothSelect} value={selectedTooth?.id || "placeholder"}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Zahn auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder" disabled>Zahn auswählen</SelectItem>
<SelectItem value="group-upper" disabled className="font-semibold">Oberkiefer</SelectItem>
{upperTeeth.map((tooth) => (
<SelectItem key={tooth.id} value={tooth.id}>
{tooth.name} {tooth.erupted && "✓"}
</SelectItem>
))}
<SelectItem value="group-lower" disabled className="font-semibold">Unterkiefer</SelectItem>
{lowerTeeth.map((tooth) => (
<SelectItem key={tooth.id} value={tooth.id}>
{tooth.name} {tooth.erupted && "✓"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col md:flex-row md:items-start md:justify-center gap-4">
<div className="flex flex-col items-center">
<h3 className="text-lg font-medium mb-2">Oberkiefer</h3>
<div className="relative w-[320px] h-[180px]">
<svg width="320" height="180" viewBox="0 0 320 180" className="absolute inset-0">
{upperTeeth.map((tooth) => (
<g key={tooth.id}>
<rect
x={tooth.x - 2}
y={tooth.y - 2}
width={tooth.width + 4}
height={tooth.height + 4}
fill="transparent"
className="cursor-pointer"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
<path
d={getToothPath(tooth, true)}
fill={tooth.erupted ? "#fecaca" : "#ffffff"}
stroke={selectedTooth?.id === tooth.id ? "#ef4444" : hoveredTooth?.id === tooth.id ? "#ef4444" : "#d1d5db"}
strokeWidth={selectedTooth?.id === tooth.id ? "3" : hoveredTooth?.id === tooth.id ? "2" : "1"}
className="cursor-pointer transition-all duration-200"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
{tooth.erupted && (
<text
x={tooth.x + tooth.width / 2}
y={tooth.y + tooth.height / 2}
textAnchor="middle"
dominantBaseline="middle"
className="text-sm font-bold fill-rose-600"
>
</text>
)}
</g>
))}
</svg>
</div>
</div>
<div className="flex flex-col items-center">
<h3 className="text-lg font-medium mb-2">Unterkiefer</h3>
<div className="relative w-[320px] h-[180px]">
<svg width="320" height="180" viewBox="0 0 320 180" className="absolute inset-0">
{lowerTeeth.map((tooth) => (
<g key={tooth.id}>
<rect
x={tooth.x - 2}
y={tooth.y - 2}
width={tooth.width + 4}
height={tooth.height + 4}
fill="transparent"
className="cursor-pointer"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
<path
d={getToothPath(tooth, false)}
fill={tooth.erupted ? "#fecaca" : "#ffffff"}
stroke={selectedTooth?.id === tooth.id ? "#ef4444" : hoveredTooth?.id === tooth.id ? "#ef4444" : "#d1d5db"}
strokeWidth={selectedTooth?.id === tooth.id ? "3" : hoveredTooth?.id === tooth.id ? "2" : "1"}
className="cursor-pointer transition-all duration-200"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
{tooth.erupted && (
<text
x={tooth.x + tooth.width / 2}
y={tooth.y + tooth.height / 2}
textAnchor="middle"
dominantBaseline="middle"
className="text-sm font-bold fill-rose-600"
>
</text>
)}
</g>
))}
</svg>
</div>
</div>
</div>
{selectedTooth && (
<div className="p-3 border rounded-lg bg-white shadow-sm">
<h4 className="font-medium mb-2">{selectedTooth.name}</h4>
{selectedTooth.erupted && selectedTooth.eruptionDate && (
<p className="text-sm text-gray-500 mb-2">
Durchbruch: {format(selectedTooth.eruptionDate, "PPP", { locale: de })}
</p>
)}
<div className="space-y-3">
<div className="space-y-1">
<label className="text-sm font-medium">
{selectedTooth.erupted ? "Datum ändern" : "Durchbruchdatum"}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!eruptionDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{eruptionDate ? (
format(eruptionDate, "PPP", { locale: de })
) : (
<span>Datum auswählen</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={eruptionDate}
onSelect={handleEruptionDateSelect}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex gap-2">
<Button
className="flex-1 bg-rose-600 text-white hover:bg-rose-700"
onClick={handleSaveEruption}
disabled={addTooth.isPending || !eruptionDate}
>
{addTooth.isPending ? "Wird gespeichert..." : (
<>
<Plus className="h-4 w-4 mr-2" />
Speichern
</>
)}
</Button>
<Button
variant="outline"
onClick={() => setSelectedTooth(null)}
disabled={addTooth.isPending}
>
Abbrechen
</Button>
</div>
</div>
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { trpc } from "@/utils/trpc";
import { toast } from "sonner";
const vaccineFormSchema = z.object({
name: z.string().min(1, "Name ist erforderlich"),
date: z.date({
required_error: "Bitte wähle ein Datum",
}),
done: z.boolean(),
notes: z.string().optional(),
});
type VaccineFormValues = z.infer<typeof vaccineFormSchema>;
interface VaccineFormProps {
childId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function VaccineForm({ childId, onSuccess, onCancel }: VaccineFormProps) {
const [date, setDate] = useState<Date | undefined>(new Date());
const form = useForm<VaccineFormValues>({
resolver: zodResolver(vaccineFormSchema),
defaultValues: {
name: "",
date: new Date(),
done: false,
notes: "",
},
});
const addVaccine = trpc.vaccine.add.useMutation({
onSuccess: () => {
toast.success("Impfung hinzugefügt", {
description: "Die Impfung wurde erfolgreich gespeichert."
});
form.reset();
setDate(new Date());
onSuccess();
},
onError: (error) => {
toast.error("Fehler", {
description: error.message || "Die Impfung konnte nicht gespeichert werden."
});
},
});
const onSubmit = form.handleSubmit((values: VaccineFormValues) => {
const localDate = new Date(
(date || values.date).getFullYear(),
(date || values.date).getMonth(),
(date || values.date).getDate(),
12, 0, 0, 0
);
addVaccine.mutate({
childId,
name: values.name,
date: localDate.toISOString(),
done: values.done,
notes: values.notes || undefined,
});
});
return (
<form onSubmit={onSubmit} className="space-y-4 p-4 border rounded-lg bg-gray-50">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-gray-700">
Impfname *
</label>
<Input
id="name"
placeholder="z.B. 6-fach Impfung"
{...form.register("name")}
disabled={addVaccine.isPending}
/>
{form.formState.errors.name && (
<p className="text-sm text-red-600">{form.formState.errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Datum *
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP", { locale: de }) : <span>Datum auswählen</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium text-gray-700">
Notizen
</label>
<Input
id="notes"
placeholder="Optionale Notizen"
{...form.register("notes")}
disabled={addVaccine.isPending}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="done"
{...form.register("done")}
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="done" className="text-sm text-gray-700">
Bereits durchgeführt
</label>
</div>
<div className="flex gap-2 pt-2">
<Button
type="submit"
disabled={addVaccine.isPending}
className="flex-1 bg-rose-600 text-white hover:bg-rose-700"
>
{addVaccine.isPending ? "Wird gespeichert..." : (
<>
<Check className="h-4 w-4 mr-2" />
Speichern
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={addVaccine.isPending}
>
<X className="h-4 w-4 mr-2" />
Abbrechen
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,252 @@
"use client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Button } from "@/components/ui/button";
import { Trash2, Check, X, Pencil } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { trpc } from "@/utils/trpc";
import { toast } from "sonner";
interface Vaccine {
id: string;
name: string;
date: Date | string;
done: boolean;
notes: string | null;
}
interface VaccineListProps {
vaccines: Vaccine[];
onRefetch: () => void;
}
export function VaccineList({ vaccines, onRefetch }: VaccineListProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDate, setEditDate] = useState<Date | undefined>();
const [editNotes, setEditNotes] = useState("");
const [editDone, setEditDone] = useState(false);
const updateVaccine = trpc.vaccine.update.useMutation({
onSuccess: () => {
onRefetch();
},
onError: (error) => {
toast.error("Fehler", { description: error.message });
},
});
const deleteVaccine = trpc.vaccine.delete.useMutation({
onSuccess: () => {
toast.success("Impfung gelöscht");
onRefetch();
},
onError: (error) => {
toast.error("Fehler", { description: error.message });
},
});
const startEdit = (vaccine: Vaccine) => {
setEditingId(vaccine.id);
setEditName(vaccine.name);
setEditDate(new Date(vaccine.date));
setEditNotes(vaccine.notes || "");
setEditDone(vaccine.done);
};
const cancelEdit = () => {
setEditingId(null);
setEditName("");
setEditDate(undefined);
setEditNotes("");
setEditDone(false);
};
const saveEdit = () => {
if (!editingId || !editDate) return;
const localDate = new Date(
editDate.getFullYear(),
editDate.getMonth(),
editDate.getDate(),
12, 0, 0, 0
);
updateVaccine.mutate({
id: editingId,
name: editName,
date: localDate.toISOString(),
done: editDone,
notes: editNotes || undefined,
});
cancelEdit();
};
const toggleDone = (vaccine: Vaccine) => {
const localDate = new Date(
new Date(vaccine.date).getFullYear(),
new Date(vaccine.date).getMonth(),
new Date(vaccine.date).getDate(),
12, 0, 0, 0
);
updateVaccine.mutate({
id: vaccine.id,
done: !vaccine.done,
date: localDate.toISOString(),
});
};
if (vaccines.length === 0) {
return <p className="text-muted-foreground">Noch keine Impfungen aufgezeichnet.</p>;
}
return (
<div className="space-y-2">
{vaccines.map((vaccine) => (
<div
key={vaccine.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
vaccine.done ? "bg-green-50 border-green-200" : "bg-white"
}`}
>
{editingId === vaccine.id ? (
<div className="flex-1 space-y-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Impfname"
disabled={updateVaccine.isPending}
/>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!editDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{editDate ? format(editDate, "PPP", { locale: de }) : <span>Datum</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={editDate}
onSelect={setEditDate}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
<Input
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder="Notizen"
disabled={updateVaccine.isPending}
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={editDone}
onChange={(e) => setEditDone(e.target.checked)}
className="h-4 w-4 rounded"
/>
<label className="text-sm">Durchgeführt</label>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveEdit} disabled={updateVaccine.isPending}>
<Check className="h-4 w-4 mr-1" /> Speichern
</Button>
<Button size="sm" variant="outline" onClick={cancelEdit} disabled={updateVaccine.isPending}>
<X className="h-4 w-4 mr-1" /> Abbrechen
</Button>
</div>
</div>
) : (
<>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{vaccine.name}</p>
{vaccine.done && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded">
Erledigt
</span>
)}
</div>
<p className="text-sm text-gray-500">
{format(new Date(vaccine.date), "PPP", { locale: de })}
</p>
{vaccine.notes && (
<p className="text-sm text-gray-400 mt-1">{vaccine.notes}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleDone(vaccine)}
disabled={updateVaccine.isPending}
className={vaccine.done ? "text-green-600" : "text-gray-400"}
>
<Check className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => startEdit(vaccine)}
disabled={updateVaccine.isPending}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Impfung löschen</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie diese Impfung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteVaccine.mutate({ id: vaccine.id })}
className="bg-red-600 text-white hover:bg-red-700"
>
Löschen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
)}
</div>
))}
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { format, differenceInMonths } from "date-fns";
import { de } from "date-fns/locale";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Plus, Baby, Activity, Calendar } from "lucide-react";
import { Plus, Baby, Activity, Calendar, Syringe } from "lucide-react";
import { trpc } from "@/utils/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { useState } from "react";
@@ -14,24 +14,29 @@ export function DashboardContent() {
const { data: children, isLoading } = trpc.child.getAllByUser.useQuery();
const [selectedChildId, setSelectedChildId] = useState<string | null>(null);
// Get measurements for the selected child
const { data: measurements } = trpc.measurement.getByChildId.useQuery(
{ childId: selectedChildId || "" },
{ enabled: !!selectedChildId }
);
// Get vaccinations for the selected child - using child router as a fallback
const { data: vaccinations } = trpc.child.getById.useQuery(
{ id: selectedChildId || "" },
const { data: vaccines } = trpc.vaccine.getByChildId.useQuery(
{ childId: selectedChildId || "" },
{ enabled: !!selectedChildId }
);
// Get toothing data for the selected child - using child router as a fallback
const { data: toothing } = trpc.child.getById.useQuery(
{ id: selectedChildId || "" },
const { data: teeth } = trpc.tooth.getByChildId.useQuery(
{ childId: selectedChildId || "" },
{ enabled: !!selectedChildId }
);
const upcomingVaccines = vaccines?.filter(v => !v.done) || [];
const nextVaccine = upcomingVaccines[0];
const eruptedTeeth = teeth?.filter(t => t.status === "durchgebrochen").length || 0;
const selectedChild = children?.find(c => c.id === selectedChildId);
const ageInMonths = selectedChild ? differenceInMonths(new Date(), new Date(selectedChild.birthDate)) : 0;
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
@@ -106,9 +111,28 @@ export function DashboardContent() {
<CardDescription className="text-zinc-500">Wichtige Termine und Erinnerungen</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-zinc-500 mb-2">Keine anstehenden Termine.</p>
</div>
{nextVaccine ? (
<div className="space-y-3">
<div className="flex items-center p-3 rounded-lg bg-blue-50 border border-blue-100">
<Syringe className="h-5 w-5 text-blue-500 mr-3" />
<div>
<p className="font-medium text-blue-900">{nextVaccine.name}</p>
<p className="text-sm text-blue-600">
Geplant: {format(new Date(nextVaccine.date), "PPP", { locale: de })}
</p>
</div>
</div>
{upcomingVaccines.length > 1 && (
<p className="text-sm text-zinc-500">
+ {upcomingVaccines.length - 1} weitere Impfungen geplant
</p>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-zinc-500 mb-2">Keine anstehenden Impfungen.</p>
</div>
)}
</CardContent>
</Card>
</div>
@@ -149,7 +173,6 @@ export function DashboardContent() {
{selectedChildId ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Measurements Summary */}
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
<h3 className="font-medium text-zinc-800 mb-2">Messungen</h3>
{measurements && measurements.length > 0 ? (
@@ -160,11 +183,11 @@ export function DashboardContent() {
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<p className="text-xs text-zinc-500">Gewicht</p>
<p className="font-medium">{measurements[0].weightKg} kg</p>
<p className="font-medium">{measurements[0].weightKg?.toFixed(1)} kg</p>
</div>
<div>
<p className="text-xs text-zinc-500">Größe</p>
<p className="font-medium">{measurements[0].heightCm} cm</p>
<p className="font-medium">{measurements[0].heightCm?.toFixed(1)} cm</p>
</div>
</div>
</div>
@@ -173,31 +196,39 @@ export function DashboardContent() {
)}
</div>
{/* Vaccinations Summary */}
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
<h3 className="font-medium text-zinc-800 mb-2">Impfungen</h3>
{vaccinations ? (
{vaccines && vaccines.length > 0 ? (
<div>
<p className="text-sm text-zinc-600">
Impfungen werden bald verfügbar sein
{vaccines.filter(v => v.done).length} von {vaccines.length} erledigt
</p>
{upcomingVaccines.length > 0 && (
<p className="text-xs text-amber-600 mt-1">
{upcomingVaccines.length} ausstehend
</p>
)}
</div>
) : (
<p className="text-sm text-zinc-500">Keine Impfungen vorhanden</p>
)}
</div>
{/* Toothing Summary */}
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
<h3 className="font-medium text-zinc-800 mb-2">Zahnung</h3>
{toothing ? (
{ageInMonths >= 5 ? (
<div>
<p className="text-sm text-zinc-600">
Zahnungsdaten werden bald verfügbar sein
{eruptedTeeth} Zähne durchgebrochen
</p>
<p className="text-xs text-zinc-500 mt-1">
von 20 Milchzähnen
</p>
</div>
) : (
<p className="text-sm text-zinc-500">Keine Zahnungsdaten vorhanden</p>
<p className="text-sm text-zinc-500">
Zahnung beginnt ca. ab 5 Monaten
</p>
)}
</div>
</div>
@@ -214,4 +245,4 @@ export function DashboardContent() {
</Card>
</div>
);
}
}

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }