"use client"; import { useMemo, useState, useTransition } from "react"; import Link from "next/link"; import { format } from "date-fns"; import { de } from "date-fns/locale"; import { NotdienstPlanStatus } from "@prisma/client"; import { ArrowRight, CheckSquare, Download, Loader2, RotateCcw, Send, Wand2, } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { generatePlanAction, publishPlanAction, updateAssignmentAction, sendNotdienstRemindersAction, resetPlanAction, } from "./actions"; interface PlanViewProps { planId?: string; remindersOpen: boolean; status?: NotdienstPlanStatus; days: { date: string; assignmentId: string | null; activeAlarmId?: string | null; canAlarm?: boolean; childId: string | null; isOriginallyUnassigned?: boolean; isInvalid?: boolean; }[]; allChildren: { id: string; name: string; parentName: string; familyId: string; }[]; familyStatuses: { id: string; name: string; childCount: number; requiredDays: number; selectedDays: { iso: string; label: string; }[]; }[]; requiredDays: number; respondedFamilies: number; openFamilies: number; totalFamilies: number; totalAvailabilitySlots: number; coverageFactor: number; closureCount: number; monthName: string; year: number; month: number; } export function PlanView({ planId, remindersOpen, status, days, allChildren, familyStatuses, requiredDays, respondedFamilies, openFamilies, totalFamilies, totalAvailabilitySlots, coverageFactor, closureCount, monthName, year, month, }: PlanViewProps) { const [isPending, startTransition] = useTransition(); const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview"); const [resetDialogOpen, setResetDialogOpen] = useState(false); const responsePercent = totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0; const handleGenerate = () => { startTransition(async () => { const result = await generatePlanAction(year, month); if ("error" in result && result.error) { toast.error(result.error as string); } else { toast.success("Plan erfolgreich generiert."); } }); }; const handlePublish = () => { if (!planId) return; startTransition(async () => { const result = await publishPlanAction(planId); if ("error" in result && result.error) { toast.error(result.error as string); } else if ("success" in result && result.success) { if (result.failed || ("deferred" in result && result.deferred)) toast.warning( "Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.", ); else toast.success( "Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.", ); } }); }; const handleSendReminders = () => { startTransition(async () => { const result = await sendNotdienstRemindersAction(year, month); if ("error" in result && result.error) { toast.error(result.error as string); } else if ("success" in result && result.success) { if (result.failed || ("deferred" in result && result.deferred)) toast.warning( `${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`, ); else toast.success( `${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`, ); } }); }; const handleReset = () => { if (!planId) return; startTransition(async () => { const result = await resetPlanAction(planId); if ("error" in result && result.error) { toast.error(result.error as string); } else { toast.success("Plan wurde erfolgreich zurückgesetzt."); setResetDialogOpen(false); } }); }; const handleAssignmentChange = (date: string, newChildId: string) => { if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return; const valueToSet = newChildId === "empty" ? null : newChildId; startTransition(async () => { const result = await updateAssignmentAction( planId, date.slice(0, 10), valueToSet, ); if ("error" in result && result.error) { toast.error(result.error as string); } else { toast.success("Zuteilung aktualisiert."); } }); }; if (!planId && totalAvailabilitySlots === 0) { return (

Noch keine Verfügbarkeiten

Für {monthName} hat noch keine Familie geantwortet. Verschicke zuerst Erinnerungen, damit die automatische Planung belastbar ist.

); } if (!planId) { return (

Verfügbarkeiten Eltern

{respondedFamilies} von {totalFamilies} Familien haben vollständig geantwortet · noch {openFamilies} offen

{responsePercent}%
{familyStatuses.map((family) => (
{family.name.slice(0, 1)}

{family.name}

{family.childCount} Kind {family.childCount === 1 ? "" : "er"}

{family.selectedDays.length > 0 ? family.selectedDays.map((day) => day.label).join(" · ") : "Noch keine Tage markiert"}
))}

Bedarfs-Übersicht

Planungsgrundlage für {monthName}.

{coverageFactor >= 1 ? "Du hast genug Verfügbarkeiten für einen Plan." : "Die Verfügbarkeiten reichen noch nicht für einen belastbaren Plan."}

Plan automatisch generieren

Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren Familien.

Faire Verteilung berücksichtigt
☑ Quote pro Familie ☑ Bevorzugte Tage ☐ Geschwister-Familien zusammen
); } const isDraft = status === "DRAFT"; return (
setActiveTab("preview")} > Plan-Vorschau setActiveTab("manual")} withDivider > Manuell zuweisen
{isDraft && ( )}
{isDraft ? "Entwurf" : "Veröffentlicht"} {isDraft ? "Du kannst die Liste jetzt manuell anpassen." : "Dieser Plan ist veröffentlicht und kann bei Änderungen nachbearbeitet werden."}
{activeTab === "preview" ? ( ) : ( )} Plan zurücksetzen? Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
); } function FamilyStatusPill({ selectedDays, requiredDays, }: { selectedDays: number; requiredDays: number; }) { if (selectedDays === 0) { return ( Noch nicht ); } return ( = requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]" } > {selectedDays} / {requiredDays} Tage ); } function PlanMetric({ label, value, badge, }: { label: string; value: number | string; badge?: "good"; }) { return (
{label} {badge ? ( {value} ) : ( {value} )}
); } function TabButton({ active, withDivider, onClick, children, }: { active: boolean; withDivider?: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } function PlanPreviewTable({ days, allChildren, }: Pick) { return (
Datum
Wochentag
Zugewiesen
Backup
Status
{days.map((day) => { const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number); const dateObj = new Date(yr, mo - 1, dy); const assignedChild = allChildren.find( (child) => child.id === day.childId, ); return (
{format(dateObj, "dd.MM.", { locale: de })}
{format(dateObj, "EEEE", { locale: de })}
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"} {assignedChild && ( ({assignedChild.name}) )} {day.activeAlarmId ? ( Aktiven Alarm öffnen ) : day.canAlarm && day.assignmentId ? ( Notdienstalarm auslösen ) : null}
Noch offen
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
); })}
); } export function ManualAssignmentTable({ days, allChildren, familyStatuses, isDraft, isPending, onAssignmentChange, }: Pick & { isDraft: boolean; isPending: boolean; onAssignmentChange: (date: string, newChildId: string) => void; }) { const availabilityByFamily = useMemo( () => new Map( familyStatuses.map((family) => [ family.id, new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))), ]), ), [familyStatuses], ); // Map childId to familyId for quick lookup const childFamilyMap = useMemo(() => { const map = new Map(); allChildren.forEach((child) => { if (child.familyId) { map.set(child.id, child.familyId); } }); return map; }, [allChildren]); // Compute family assignment counts in real-time based on current selections const familyAssignmentCounts = useMemo(() => { const counts = new Map(); days.forEach((day) => { if (day.childId) { const famId = childFamilyMap.get(day.childId); if (famId) { counts.set(famId, (counts.get(famId) ?? 0) + 1); } } }); return counts; }, [days, childFamilyMap]); return (
Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl gilt für den gesamten Haushalt.
Datum
Eingeteilte Familie
{days.map((day) => { const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number); const dateObj = new Date(yr, mo - 1, dy); const dateKey = day.date.slice(0, 10); const available = allChildren.filter((child) => availabilityByFamily.get(child.familyId)?.has(dateKey), ); const unavailable = allChildren.filter( (child) => !availabilityByFamily.get(child.familyId)?.has(dateKey), ); const availableFamilies = new Set( available.map((child) => child.familyId), ).size; const selectedChild = allChildren.find( (child) => child.id === day.childId, ); const selectedAvailable = !!selectedChild && availabilityByFamily.get(selectedChild.familyId)?.has(dateKey); const option = (child: PlanViewProps["allChildren"][number]) => ( ); return (
{format(dateObj, "EE, dd.MM.", { locale: de })}
{!day.isInvalid ? (

{selectedChild ? selectedAvailable ? "Eingeteilte Familie ist verfügbar." : "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben." : `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`}

) : null}
{day.isInvalid ? ( Einteilung nicht mehr gültig – bitte entfernen. ) : null} {!isDraft && ( Veröffentlicht )} {day.assignmentId && day.canAlarm && ( {day.activeAlarmId ? "Aktiven Alarm öffnen" : "Notdienstalarm auslösen"} )}
); })}
); }