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:
@@ -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>
|
||||
)}
|
||||
|
||||
206
src/components/child/child-edit-dialog.tsx
Normal file
206
src/components/child/child-edit-dialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
416
src/components/child/denture-visualization.tsx
Normal file
416
src/components/child/denture-visualization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
src/components/child/vaccine-form.tsx
Normal file
176
src/components/child/vaccine-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/components/child/vaccine-list.tsx
Normal file
252
src/components/child/vaccine-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user