From 84aae1d72ef14ba30a931e5dbeefcd61511906ca Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Thu, 21 May 2026 10:44:23 +0200 Subject: [PATCH] Implement missing UI button features, filters, search, and view toggles across all modules --- UI-TODO.md | 78 ++ .../_components/absences-client.tsx | 441 ++++++++++ .../dashboard/admin/abwesenheiten/page.tsx | 269 +----- .../_components/directory-client.tsx | 600 ++++++++++++++ src/app/dashboard/adressbuch/page.tsx | 283 +------ .../_components/families-list-client.tsx | 418 ++++++++++ src/app/dashboard/families/page.tsx | 229 +----- .../_components/admin-termin-modal.tsx | 69 +- .../_components/calendar-overview-client.tsx | 774 ++++++++++++++++++ .../kalender/_components/termin-list.tsx | 290 +++++-- src/app/dashboard/kalender/actions.ts | 25 + src/app/dashboard/kalender/page.tsx | 100 +-- src/app/dashboard/layout.tsx | 4 +- .../plan/_components/settings-modal.tsx | 132 +++ src/app/dashboard/notdienst/plan/actions.ts | 189 ++++- src/app/dashboard/notdienst/plan/page.tsx | 61 +- .../dashboard/notdienst/plan/plan-view.tsx | 98 ++- .../NotdienstAvailabilityReminderEmail.tsx | 166 ++++ 18 files changed, 3268 insertions(+), 958 deletions(-) create mode 100644 UI-TODO.md create mode 100644 src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx create mode 100644 src/app/dashboard/adressbuch/_components/directory-client.tsx create mode 100644 src/app/dashboard/families/_components/families-list-client.tsx create mode 100644 src/app/dashboard/kalender/_components/calendar-overview-client.tsx create mode 100644 src/app/dashboard/notdienst/plan/_components/settings-modal.tsx create mode 100644 src/emails/NotdienstAvailabilityReminderEmail.tsx diff --git a/UI-TODO.md b/UI-TODO.md new file mode 100644 index 0000000..9c3f82a --- /dev/null +++ b/UI-TODO.md @@ -0,0 +1,78 @@ +# UI-ToDo: klickbare Elemente ohne Funktion + +Stand: 2026-05-20 + +Geprüft lokal als Admin mit Demo-Login `admin@waldameisen.local`. + +## Notdienst-Planung + +- [ ] `src/app/dashboard/notdienst/plan/page.tsx:186` + Monat zurück/vor haben keinen Handler oder Link. + +- [ ] `src/app/dashboard/notdienst/plan/page.tsx:196` + `Einstellungen` ist klickbar, öffnet aber nichts und navigiert nicht. + +- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:135` + `Erinnerungen verschicken` im Empty-State ist ohne Funktion. + +- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:202` + `Erinnerung an offene senden (...)` ist ohne Funktion. + +- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:312` + `Als PDF exportieren` ist ohne Funktion. + +- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:316` + `Plan zurücksetzen` ist ohne Funktion. + +## Terminkalender + +- [ ] `src/app/dashboard/kalender/page.tsx:296` + Umschalter `Liste / Monat / Agenda` ändern keine Ansicht. + +- [ ] `src/app/dashboard/kalender/page.tsx:317` + Suche `Termine suchen` filtert aktuell nicht. + +- [ ] `src/app/dashboard/kalender/page.tsx:415` + Kategorie-Filterchips sind nur visuell. + +- [ ] `src/app/dashboard/kalender/_components/termin-list.tsx:152` + `Mehr Optionen` bei Elterndiensten ist ohne Funktion. + +- [ ] `src/app/dashboard/kalender/_components/termin-list.tsx:191` + `Mehr Optionen` bei Terminen ist ohne Funktion. + +## Adressbuch + +- [ ] `src/app/dashboard/adressbuch/page.tsx:161` + `Sichtbarkeit` ist ohne Funktion. + +- [ ] `src/app/dashboard/adressbuch/page.tsx:193` + Suche `Familien, Namen oder E-Mail suchen` filtert aktuell nicht. + +- [ ] `src/app/dashboard/adressbuch/page.tsx:201` + Filterchips `Alle Gruppen`, `Mit Telefon`, `Mit Ämtern`, `Nur Opt-in` sind nur visuell. + +- [ ] `src/app/dashboard/adressbuch/page.tsx:334` + `Kontakt öffnen` ist ohne Funktion. + +## Familienverwaltung + +- [ ] `src/app/dashboard/families/page.tsx:117` + `Ansicht` ist ohne Funktion. + +- [ ] `src/app/dashboard/families/page.tsx:133` + Suche `Familien, Eltern oder Kinder suchen` filtert aktuell nicht. + +## Abwesenheiten Admin + +- [ ] `src/app/dashboard/admin/abwesenheiten/page.tsx:132` + Filterchips `Alle Gruppen` und `Alle Gründe` sind klickbar, aber ohne Funktion. + +## Bereits angebunden / nicht auffaellig + +- Dialoge und Mutationsaktionen sind bei der Pruefung nicht als No-Op aufgefallen: + - Familie anlegen/bearbeiten + - ErzieherIn anlegen/bearbeiten/loeschen + - Profil speichern + - Termin anlegen/anfragen + - Dienstplan-Zuweisungen diff --git a/src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx b/src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx new file mode 100644 index 0000000..9752172 --- /dev/null +++ b/src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx @@ -0,0 +1,441 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { addDays, format, isWeekend, parseISO } from "date-fns"; +import { de } from "date-fns/locale"; +import { + ArrowLeft, + ArrowRight, + CalendarCheck2, + CalendarDays, + ChevronDown, + ChevronRight, + Stethoscope, +} from "lucide-react"; +import { AbsenceReason } from "@prisma/client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { AbsenceTable, type AbsenceTableItem } from "../absence-table"; +import { reasonColor, reasonLabel } from "@/lib/absence-utils"; + +interface AbsencesClientProps { + initialAbsences: AbsenceTableItem[]; + childCount: number; + upcomingAbsenceCount: number; + closure: { title: string; type: string } | null; + dateKey: string; + selectedDateStr: string; + isToday: boolean; + isTooFarFuture: boolean; + isClosedDay: boolean; + todayStr: string; + error: string | null; +} + +export function AbsencesClient({ + initialAbsences, + childCount, + upcomingAbsenceCount, + closure, + dateKey, + selectedDateStr, + isToday, + isTooFarFuture, + isClosedDay, + todayStr, + error, +}: AbsencesClientProps) { + const router = useRouter(); + const selectedDate = parseISO(selectedDateStr); + const today = parseISO(todayStr); + + // Filters State + const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all"); + const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all"); + + // Dropdown UI state + const [isGroupOpen, setIsGroupOpen] = useState(false); + const [isReasonOpen, setIsReasonOpen] = useState(false); + + // Date input state + const [inputDate, setInputDate] = useState(dateKey); + + // Group resolver function + const mockGroup = (childName: string) => { + const charCode = childName.charCodeAt(0); + return charCode % 2 === 0 ? "Krippe" : "Elementar"; + }; + + // Filter computation + const filteredAbsences = useMemo(() => { + return initialAbsences.filter((item) => { + if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) { + return false; + } + if (selectedReason !== "all" && item.reason !== selectedReason) { + return false; + } + return true; + }); + }, [initialAbsences, selectedGroup, selectedReason]); + + // Recalculate reasons count based on filtered absences + const countsByReason = useMemo(() => { + return filteredAbsences.reduce>>((acc, item) => { + acc[item.reason] = (acc[item.reason] ?? 0) + 1; + return acc; + }, {}); + }, [filteredAbsences]); + + const handleDateNavigate = (targetDate: Date) => { + const formatted = format(targetDate, "yyyy-MM-dd"); + setInputDate(formatted); + router.push(`/dashboard/admin/abwesenheiten?date=${formatted}`); + }; + + const handleDateFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (inputDate) { + router.push(`/dashboard/admin/abwesenheiten?date=${inputDate}`); + } + }; + + return ( +
+
+
+

+ TAGESÜBERSICHT +

+

+ Heute fehlen{" "} + + {filteredAbsences.length} Kinder + +

+

+ {format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })} +

+
+ +
+ + + {childCount - filteredAbsences.length} anwesend + +
+ + + +
+
+ setInputDate(e.target.value)} + className="w-[140px]" + /> + +
+
+
+ +
+
+ {/* Group Dropdown Filter */} +
+ + {isGroupOpen && ( + <> +
setIsGroupOpen(false)} /> +
+ + + +
+ + )} +
+ + {/* Reason Dropdown Filter */} +
+ + {isReasonOpen && ( + <> +
setIsReasonOpen(false)} /> +
+ + + + +
+ + )} +
+
+ + {filteredAbsences.length > 0 ? ( +
+ + + {reasonLabel(AbsenceReason.ILLNESS)} {countsByReason[AbsenceReason.ILLNESS] ?? 0} + + + + {reasonLabel(AbsenceReason.OTHER)} {countsByReason[AbsenceReason.OTHER] ?? 0} + + + + {reasonLabel(AbsenceReason.VACATION)} {countsByReason[AbsenceReason.VACATION] ?? 0} + + + letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })} + +
+ ) : null} +
+ +
+ {error ? ( + + + {error} + + + ) : filteredAbsences.length > 0 ? ( + + +
+ + {filteredAbsences.length} von {childCount} Kindern abwesend ·{" "} + {upcomingAbsenceCount} anstehend + + {dateKey} +
+
+ + + +
+ ) : isTooFarFuture ? ( + + ) : isClosedDay ? ( + + ) : ( + + )} +
+
+ ); +} + +function EmptyState({ + tone, + title, + description, +}: { + tone: "good" | "accent"; + title: string; + description: string; +}) { + const isGood = tone === "good"; + + return ( + + +
+ {isGood ? ( + + ) : ( + + )} +
+

+ {title} +

+

+ {description} +

+ + Wochenübersicht öffnen + + +
+
+ ); +} diff --git a/src/app/dashboard/admin/abwesenheiten/page.tsx b/src/app/dashboard/admin/abwesenheiten/page.tsx index 2dc7cdc..f878c2f 100644 --- a/src/app/dashboard/admin/abwesenheiten/page.tsx +++ b/src/app/dashboard/admin/abwesenheiten/page.tsx @@ -1,29 +1,11 @@ -import Link from "next/link"; -import { addDays, differenceInCalendarDays, format, isWeekend } from "date-fns"; -import { de } from "date-fns/locale"; -import { - ArrowLeft, - ArrowRight, - CalendarCheck2, - CalendarDays, - ChevronDown, - ChevronRight, - Stethoscope, -} from "lucide-react"; -import { AbsenceReason, TerminStatus, TerminType, UserRole } from "@prisma/client"; +import { differenceInCalendarDays, format, isWeekend } from "date-fns"; +import { TerminStatus, TerminType, UserRole } from "@prisma/client"; import { getDailyAbsences } from "@/actions/absences"; import { requireRole } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { - AbsenceTable, - type AbsenceTableItem, -} from "./absence-table"; -import { reasonColor, reasonLabel } from "@/lib/absence-utils"; +import { AbsencesClient } from "./_components/absences-client"; +import { type AbsenceTableItem } from "./absence-table"; export const metadata = { title: "Abwesenheiten · Kita-Planer" }; @@ -76,229 +58,23 @@ export default async function AdminAbwesenheitenPage({ startDate: absence.startDate, endDate: absence.endDate, })); - const countsByReason = countByReason(tableItems); return (
-
-
-

- TAGESÜBERSICHT -

-

- Heute fehlen{" "} - - {absences.length} Kinder - -

-

- {format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })} -

-
- -
- - - {childCount - absences.length} anwesend - -
- - - Zurück - - - Heute - - - Vor - - -
-
- - -
-
-
- -
-
- - -
- {tableItems.length > 0 ? ( -
- - - - - letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })} - -
- ) : null} -
- -
- {result.error ? ( - - - {result.error} - - - ) : tableItems.length > 0 ? ( - - -
- - {tableItems.length} von {childCount} Kindern abwesend ·{" "} - {upcomingAbsenceCount} anstehend - - {dateKey} -
-
- - - -
- ) : isTooFarFuture ? ( - - ) : isClosedDay ? ( - - ) : ( - - )} -
-
- ); -} - -function SegmentLink({ - href, - active, - children, -}: { - href: string; - active?: boolean; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} - -function FilterChip({ label }: { label: string }) { - return ( - - ); -} - -function ReasonInlineStat({ - reason, - count, -}: { - reason: AbsenceReason; - count: number; -}) { - return ( - - - {reasonLabel(reason)} {count} - - ); -} - -function EmptyState({ - tone, - title, - description, -}: { - tone: "good" | "accent"; - title: string; - description: string; -}) { - const isGood = tone === "good"; - - return ( - - -
- {isGood ? ( - - ) : ( - - )} -
-

- {title} -

-

- {description} -

- - Wochenübersicht öffnen - - -
-
+
); } @@ -308,19 +84,8 @@ function parseDateParam(value: string | undefined, fallback: Date) { return Number.isNaN(parsed.getTime()) ? fallback : parsed; } -function hrefForDate(date: Date) { - return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`; -} - function startOfLocalDay(date: Date) { const nextDate = new Date(date); nextDate.setHours(0, 0, 0, 0); return nextDate; } - -function countByReason(items: AbsenceTableItem[]) { - return items.reduce>>((acc, item) => { - acc[item.reason] = (acc[item.reason] ?? 0) + 1; - return acc; - }, {}); -} diff --git a/src/app/dashboard/adressbuch/_components/directory-client.tsx b/src/app/dashboard/adressbuch/_components/directory-client.tsx new file mode 100644 index 0000000..7a4eeff --- /dev/null +++ b/src/app/dashboard/adressbuch/_components/directory-client.tsx @@ -0,0 +1,600 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + Baby, + Contact, + Mail, + Phone, + Search, + ShieldCheck, + SlidersHorizontal, + UserRound, + Home, + MapPin, +} from "lucide-react"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export type DirectoryParentDto = { + id: string; + firstName: string; + lastName: string; + hasDirectoryOptIn: boolean; + email?: string; + phone?: string; + street?: string; + postalCode?: string; + city?: string; + duties: { + id: string; + name: string; + }[]; +}; + +export type DirectoryFamilyDto = { + id: string; + name: string; + children: { + id: string; + firstName: string; + lastName: string; + dateOfBirth?: Date | null; + }[]; + parents: DirectoryParentDto[]; +}; + +export function DirectoryClient({ + initialFamilies, +}: { + initialFamilies: DirectoryFamilyDto[]; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const [activeFilters, setActiveFilters] = useState>(new Set()); + const [selectedFamily, setSelectedFamily] = useState(null); + const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false); + + const toggleFilter = (filterKey: string) => { + setActiveFilters((prev) => { + const next = new Set(prev); + if (next.has(filterKey)) { + next.delete(filterKey); + } else { + next.add(filterKey); + } + return next; + }); + }; + + const filteredFamilies = useMemo(() => { + return initialFamilies.filter((family) => { + // 1. Search Query filter + const query = searchQuery.trim().toLowerCase(); + if (query) { + const familyNameMatch = family.name.toLowerCase().includes(query); + const childMatch = family.children.some( + (c) => + c.firstName.toLowerCase().includes(query) || + c.lastName.toLowerCase().includes(query) + ); + const parentMatch = family.parents.some( + (p) => + p.firstName.toLowerCase().includes(query) || + p.lastName.toLowerCase().includes(query) || + (p.email && p.email.toLowerCase().includes(query)) + ); + if (!familyNameMatch && !childMatch && !parentMatch) return false; + } + + // 2. Custom filters + if (activeFilters.has("telefon")) { + const hasPhone = family.parents.some((p) => p.hasDirectoryOptIn && p.phone); + if (!hasPhone) return false; + } + + if (activeFilters.has("aemter")) { + const hasDuties = family.parents.some((p) => p.duties.length > 0); + if (!hasDuties) return false; + } + + if (activeFilters.has("optIn")) { + // Show households where all parents in the system have opted in + const allOptIn = family.parents.every((p) => p.hasDirectoryOptIn); + if (!allOptIn) return false; + } + + return true; + }); + }, [initialFamilies, searchQuery, activeFilters]); + + // Recalculate metrics for filtered subset + const visibleParentCount = useMemo(() => { + return filteredFamilies.reduce( + (count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length, + 0 + ); + }, [filteredFamilies]); + + const childCount = useMemo(() => { + return filteredFamilies.reduce((count, fam) => count + fam.children.length, 0); + }, [filteredFamilies]); + + const totalVisibleParentCount = useMemo(() => { + return initialFamilies.reduce( + (count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length, + 0 + ); + }, [initialFamilies]); + + const groupedFamilies = useMemo(() => { + const groups = new Map(); + filteredFamilies.forEach((family) => { + const initial = family.name.trim().slice(0, 1).toUpperCase(); + const key = initial || "#"; + groups.set(key, [...(groups.get(key) ?? []), family]); + }); + return Array.from(groups.entries()) + .map(([initial, families]) => ({ initial, families })) + .sort((a, b) => a.initial.localeCompare(b.initial)); + }, [filteredFamilies]); + + return ( +
+ {/* Header */} +
+
+

+ KONTAKTE · ADRESSBUCH +

+

+ Freigegebene{" "} + + Kontakte + +

+

+ Haushalte und Kontaktdaten, die explizit für das interne + Kita-Adressbuch freigegeben wurden. +

+
+
+ + + {totalVisibleParentCount} sichtbar + + +
+
+ + {/* Main Household List or Empty State */} + {initialFamilies.length === 0 ? ( + + +
+ +
+

+ Keine freigegebenen Kontakte +

+

+ Sobald mindestens ein Elternteil eines Haushalts zustimmt, + erscheint die Familie hier. +

+
+
+ ) : ( +
+ {/* Metrics & Search Bar */} +
+
+ + + +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Custom Filter Chips */} +
+ + + + +
+ + {/* Household list cards wrapper */} + {filteredFamilies.length === 0 ? ( + + +
+ +
+

+ Keine passenden Kontakte gefunden +

+

+ Passe deine Suche oder die Filterchips an, um Einträge zu sehen. +

+
+
+ ) : ( + + {groupedFamilies.map((group) => ( +
+
+

+ {group.initial} +

+ + {group.families.length} + +
+
+ {group.families.map((family) => { + const visibleParents = family.parents.filter( + (parent) => parent.hasDirectoryOptIn + ); + const duties = family.parents.flatMap((parent) => parent.duties); + + return ( +
+ {/* Left: Family Name & Children */} +
+
+ {family.name.slice(0, 1)} +
+
+

+ {family.name} +

+

+ + {family.children.length === 0 + ? "Keine Kinder hinterlegt" + : family.children + .map((child) => `${child.firstName} ${child.lastName}`) + .join(", ")} +

+
+
+ + {/* Middle: Parents */} +
+ {family.parents.map((user) => ( +
+
+ +

+ {user.firstName} {user.lastName} +

+ {user.hasDirectoryOptIn ? ( + Opt-in + ) : ( + + nicht freigegeben + + )} +
+ + {user.hasDirectoryOptIn && ( +
+ {user.email && ( + + + + {user.email} + + + )} + {user.phone ? ( + + + {user.phone} + + ) : ( + + Keine Telefonnummer + + )} +
+ )} +
+ ))} +
+ + {/* Right: Badges & Details Action */} +
+
+ + {visibleParents.length} von {family.parents.length} sichtbar + + {duties.map((duty) => ( + + {duty.name} + + ))} +
+ +
+
+ ); + })} +
+
+ ))} +
+ )} +
+ )} + + {/* Household Detail Dialog */} + !open && setSelectedFamily(null)} + > + + {selectedFamily && ( + <> + +
+ {selectedFamily.name.slice(0, 1)} +
+ Familie {selectedFamily.name} + + Detaillierte Kontaktdaten und Haushalts-Zugehörigkeiten. + +
+ +
+ {/* Children Details */} +
+

+ + Kinder im Haushalt +

+
+ {selectedFamily.children.length === 0 ? ( +

Keine Kinder hinterlegt.

+ ) : ( + selectedFamily.children.map((child) => ( +
+
+ + + {child.firstName} {child.lastName} + +
+ {child.dateOfBirth && ( + + Geboren: {format(new Date(child.dateOfBirth), "dd.MM.yyyy", { locale: de })} + + )} +
+ )) + )} +
+
+ + {/* Parents/Users Details */} +
+

+ + Eltern & Kontakte +

+
+ {selectedFamily.parents.map((parent) => ( +
+
+ + + {parent.firstName} {parent.lastName} + + {parent.hasDirectoryOptIn ? ( + Freigegeben + ) : ( + Privat + )} +
+ + {parent.hasDirectoryOptIn ? ( +
+ {parent.email && ( + + + {parent.email} + + )} + {parent.phone ? ( + + + {parent.phone} + + ) : ( +
+ + Keine Telefonnummer hinterlegt +
+ )} + + {/* Address details */} + {(parent.street || parent.postalCode || parent.city) && ( +
+ +
+ {parent.street &&

{parent.street}

} + {(parent.postalCode || parent.city) && ( +

+ {parent.postalCode} {parent.city} +

+ )} +
+
+ )} +
+ ) : ( +

+ Dieser Kontakt hat seine Daten nicht für das Adressbuch freigegeben. +

+ )} + + {parent.duties.length > 0 && ( +
+ {parent.duties.map((duty) => ( + + {duty.name} + + ))} +
+ )} +
+ ))} +
+
+
+ + )} +
+
+ + {/* Global Privacy settings dialog */} + + + + + + Sichtbarkeit & Datenschutz + + + Deine Privatsphäre ist uns wichtig. In diesem Adressbuch werden Kontaktdaten + nur für Haushalte angezeigt, die dem Opt-in für das interne Kita-Adressbuch + explizit zugestimmt haben. +
+
+ Du kannst deine eigene Zustimmung sowie deine E-Mail-Adresse und Telefonnummer + jederzeit in deinen persönlichen Profileinstellungen einsehen und anpassen. +
+
+
+ + + + +
+
+
+
+ ); +} + +function DirectoryMetric({ label, value }: { label: string; value: number }) { + return ( + + {value} + {label} + + ); +} diff --git a/src/app/dashboard/adressbuch/page.tsx b/src/app/dashboard/adressbuch/page.tsx index 994b7e1..9719cdf 100644 --- a/src/app/dashboard/adressbuch/page.tsx +++ b/src/app/dashboard/adressbuch/page.tsx @@ -1,45 +1,9 @@ -import { - Baby, - Contact, - Mail, - Phone, - Search, - ShieldCheck, - SlidersHorizontal, - UserRound, -} from "lucide-react"; - import { requireKitaSession } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; +import { DirectoryClient, type DirectoryFamilyDto } from "./_components/directory-client"; export const metadata = { title: "Adressbuch · Kita-Planer" }; -type DirectoryFamilyDto = { - id: string; - name: string; - children: { - id: string; - firstName: string; - lastName: string; - }[]; - parents: { - id: string; - firstName: string; - lastName: string; - hasDirectoryOptIn: boolean; - email?: string; - phone?: string; - duties: { - id: string; - name: string; - }[]; - }[]; -}; - export default async function AdressbuchPage() { const session = await requireKitaSession(); @@ -60,6 +24,7 @@ export default async function AdressbuchPage() { id: true, firstName: true, lastName: true, + dateOfBirth: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }, @@ -96,6 +61,9 @@ export default async function AdressbuchPage() { id: true, email: true, phone: true, + street: true, + postalCode: true, + city: true, }, }); const visibleContactsByUserId = new Map( @@ -116,6 +84,9 @@ export default async function AdressbuchPage() { hasDirectoryOptIn, email: contact?.email, phone: contact?.phone ?? undefined, + street: contact?.street ?? undefined, + postalCode: contact?.postalCode ?? undefined, + city: contact?.city ?? undefined, duties: user.dutyAssignments.map((assignment) => ({ id: assignment.id, name: assignment.duty.name, @@ -124,241 +95,5 @@ export default async function AdressbuchPage() { }), })); - const visibleParentCount = families.reduce( - (count, family) => - count + family.parents.filter((parent) => parent.hasDirectoryOptIn).length, - 0, - ); - const childCount = families.reduce( - (count, family) => count + family.children.length, - 0, - ); - const groupedFamilies = groupFamiliesByInitial(families); - - return ( -
-
-
-

- KONTAKTE · ADRESSBUCH -

-

- Freigegebene{" "} - - Kontakte - -

-

- Haushalte und Kontaktdaten, die explizit für das interne - Kita-Adressbuch freigegeben wurden. -

-
-
- - - {visibleParentCount} sichtbar - - -
-
- - {families.length === 0 ? ( - - -
- -
-

- Keine freigegebenen Kontakte -

-

- Sobald mindestens ein Elternteil eines Haushalts zustimmt, - erscheint die Familie hier. -

-
-
- ) : ( -
-
-
- - - -
-
- - -
-
- -
- {["Alle Gruppen", "Mit Telefon", "Mit Ämtern", "Nur Opt-in"].map( - (filter, index) => ( - - {filter} - - ), - )} -
- - - {groupedFamilies.map((group) => ( -
-
-

- {group.initial} -

- - {group.families.length} - -
-
- {group.families.map((family) => ( - - ))} -
-
- ))} -
-
- )} -
- ); -} - -function FamilyDirectoryRow({ family }: { family: DirectoryFamilyDto }) { - const visibleParents = family.parents.filter( - (parent) => parent.hasDirectoryOptIn, - ); - const duties = family.parents.flatMap((parent) => parent.duties); - - return ( -
-
-
- {family.name.slice(0, 1)} -
-
-

- {family.name} -

-

- - {family.children.length === 0 - ? "Keine Kinder hinterlegt" - : family.children - .map((child) => `${child.firstName} ${child.lastName}`) - .join(", ")} -

-
-
- -
- {family.parents.map((user) => ( -
-
- -

- {user.firstName} {user.lastName} -

- {user.hasDirectoryOptIn ? ( - Opt-in - ) : ( - - nicht freigegeben - - )} -
- - {user.hasDirectoryOptIn && ( -
- {user.email && ( - - - - {user.email} - - - )} - {user.phone ? ( - - - {user.phone} - - ) : ( - - Keine Telefonnummer - - )} -
- )} -
- ))} -
- -
-
- - {visibleParents.length} von {family.parents.length} sichtbar - - {duties.map((duty) => ( - - {duty.name} - - ))} -
- -
-
- ); -} - -function DirectoryMetric({ label, value }: { label: string; value: number }) { - return ( - - {value} - {label} - - ); -} - -function groupFamiliesByInitial(families: DirectoryFamilyDto[]) { - const groups = new Map(); - - families.forEach((family) => { - const initial = family.name.trim().slice(0, 1).toLocaleUpperCase("de-DE"); - const key = initial || "#"; - groups.set(key, [...(groups.get(key) ?? []), family]); - }); - - return Array.from(groups.entries()).map(([initial, groupFamilies]) => ({ - initial, - families: groupFamilies, - })); + return ; } diff --git a/src/app/dashboard/families/_components/families-list-client.tsx b/src/app/dashboard/families/_components/families-list-client.tsx new file mode 100644 index 0000000..5c1ac8e --- /dev/null +++ b/src/app/dashboard/families/_components/families-list-client.tsx @@ -0,0 +1,418 @@ +"use client"; + +import { useState } from "react"; +import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table as UITable, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { AddFamilyDialog } from "../add-family-dialog"; +import { DutyManager } from "../duty-manager"; +import { EditFamilyDialog } from "../edit-family-dialog"; + +interface FamilyUser { + id: string; + firstName: string; + lastName: string; + email: string; + emailVerifiedAt: Date | null; + dutyAssignments: { + id: string; + dutyId: string; + duty: { + name: string; + }; + }[]; +} + +interface FamilyChild { + id: string; + firstName: string; + lastName: string; +} + +interface FamilyData { + id: string; + name: string; + users: FamilyUser[]; + children: FamilyChild[]; +} + +interface DutyData { + id: string; + name: string; + assignments: { + id: string; + }[]; +} + +interface FamiliesListClientProps { + initialFamilies: FamilyData[]; + allDuties: DutyData[]; + canManageDuties: boolean; +} + +export function FamiliesListClient({ + initialFamilies, + allDuties, + canManageDuties, +}: FamiliesListClientProps) { + const [viewMode, setViewMode] = useState<"table" | "cards">("table"); + const [searchQuery, setSearchQuery] = useState(""); + + const filteredFamilies = initialFamilies.filter((family) => { + const q = searchQuery.toLowerCase().trim(); + if (!q) return true; + + // Check family name + if (family.name.toLowerCase().includes(q)) return true; + + // Check parents + const matchesParent = family.users.some( + (user) => + user.firstName.toLowerCase().includes(q) || + user.lastName.toLowerCase().includes(q) || + user.email.toLowerCase().includes(q) + ); + if (matchesParent) return true; + + // Check children + const matchesChild = family.children.some( + (child) => + child.firstName.toLowerCase().includes(q) || + child.lastName.toLowerCase().includes(q) + ); + return matchesChild; + }); + + const childCount = filteredFamilies.reduce( + (acc, family) => acc + family.children.length, + 0 + ); + + const pendingInviteCount = filteredFamilies.reduce( + (acc, family) => + acc + family.users.filter((user) => !user.emailVerifiedAt).length, + 0 + ); + + return ( +
+
+
+

+ VERWALTUNG · FAMILIEN +

+

+ Familien{" "} + + organisieren + +

+

+ Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen. +

+
+
+
); } - -function EmptyState() { - return ( - -
-
- -
-

- Noch keine Familien -

-

- Lege die erste Familie an und lade das Elternteil per Link ein, sein - Passwort zu setzen. -

- -
-
- ); -} - -function DirectoryMetric({ label, value }: { label: string; value: number }) { - return ( - - - {value} - - {label} - - ); -} diff --git a/src/app/dashboard/kalender/_components/admin-termin-modal.tsx b/src/app/dashboard/kalender/_components/admin-termin-modal.tsx index c18a4d4..b6c5681 100644 --- a/src/app/dashboard/kalender/_components/admin-termin-modal.tsx +++ b/src/app/dashboard/kalender/_components/admin-termin-modal.tsx @@ -1,7 +1,7 @@ "use client"; import type { ComponentProps, FormEvent } from "react"; -import { useMemo, useState, useTransition } from "react"; +import { useEffect, useMemo, useState, useTransition } from "react"; import { TerminType } from "@prisma/client"; import { Plus } from "lucide-react"; import { toast } from "sonner"; @@ -18,7 +18,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { createTerminAdmin } from "../actions"; +import { createTerminAdmin, updateTerminAdmin } from "../actions"; import { Textarea } from "@/components/ui/textarea"; type AdminTerminFormState = { @@ -43,12 +43,22 @@ type AdminTerminModalProps = { triggerLabel?: string; triggerVariant?: ComponentProps["variant"]; triggerClassName?: string; + existingTermin?: { + id: string; + title: string; + description: string | null; + type: TerminType; + startDate: Date; + endDate: Date; + allDay: boolean; + }; }; export function AdminTerminModal({ triggerLabel = "Termin direkt anlegen", triggerVariant, triggerClassName = "gap-2", + existingTermin, }: AdminTerminModalProps) { const [open, setOpen] = useState(false); const [formState, setFormState] = useState(initialFormState); @@ -56,6 +66,24 @@ export function AdminTerminModal({ const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []); const minEndDateTime = formState.startDate || minDateTime; + // Initialize and reset form fields when modal opens + useEffect(() => { + if (open) { + if (existingTermin) { + setFormState({ + title: existingTermin.title, + description: existingTermin.description || "", + type: existingTermin.type, + startDate: getLocalDateTimeInputValue(existingTermin.startDate), + endDate: getLocalDateTimeInputValue(existingTermin.endDate), + allDay: existingTermin.allDay, + }); + } else { + setFormState(initialFormState); + } + } + }, [open, existingTermin]); + const updateField = ( key: TKey, value: AdminTerminFormState[TKey], @@ -76,7 +104,8 @@ export function AdminTerminModal({ toast.error("Bitte Start und Ende ausfüllen."); return; } - if (formState.startDate < minDateTime) { + // Only enforce future start date when creating a new event + if (!existingTermin && formState.startDate < minDateTime) { toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen."); return; } @@ -95,12 +124,24 @@ export function AdminTerminModal({ }; startTransition(async () => { - const res = await createTerminAdmin(data); + let res; + if (existingTermin) { + res = await updateTerminAdmin(existingTermin.id, data); + } else { + res = await createTerminAdmin(data); + } + if (res.error) { toast.error(res.error); } else { - toast.success("Termin wurde direkt angelegt!"); - setFormState(initialFormState); + toast.success( + existingTermin + ? "Termin wurde erfolgreich aktualisiert!" + : "Termin wurde direkt angelegt!" + ); + if (!existingTermin) { + setFormState(initialFormState); + } setOpen(false); } }); @@ -110,16 +151,20 @@ export function AdminTerminModal({
- Termin anlegen (Admin) + + {existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"} + - Dieser Termin wird sofort freigegeben und ist für alle sichtbar. + {existingTermin + ? "Nimm Änderungen an diesem Termin vor. Die Änderungen werden sofort wirksam." + : "Dieser Termin wird sofort freigegeben und ist für alle sichtbar."} @@ -177,7 +222,7 @@ export function AdminTerminModal({ id="startDate" name="startDate" type="datetime-local" - min={minDateTime} + min={existingTermin ? undefined : minDateTime} value={formState.startDate} onChange={(event) => updateField("startDate", event.target.value)} required @@ -189,7 +234,7 @@ export function AdminTerminModal({ id="endDate" name="endDate" type="datetime-local" - min={minEndDateTime} + min={formState.startDate || (existingTermin ? undefined : minDateTime)} value={formState.endDate} onChange={(event) => updateField("endDate", event.target.value)} required @@ -217,7 +262,7 @@ export function AdminTerminModal({ Abbrechen
diff --git a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx new file mode 100644 index 0000000..889df2a --- /dev/null +++ b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx @@ -0,0 +1,774 @@ +"use client"; + +import { useState, useMemo, useTransition } from "react"; +import { + TerminType, + TerminStatus, + EventParticipationStatus, + DutyAssignmentStatus, +} from "@prisma/client"; +import { + format, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + isSameMonth, + isSameDay, + addMonths, + subMonths, +} from "date-fns"; +import { de } from "date-fns/locale"; +import { + Search, + ListFilter, + ChevronLeft, + ChevronRight, + Calendar as CalendarIcon, + Clock, + MapPin, + AlertTriangle, + WashingMachine, + MoreHorizontal, + Trash2, + Edit, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { toast } from "sonner"; + +import { CalendarListItemDto } from "./termin-list"; +import { TerminList } from "./termin-list"; +import { MitbringselList } from "./mitbringsel-list"; +import { AdminTerminModal } from "./admin-termin-modal"; +import { deleteTerminAdmin } from "../actions"; + +const categories = [ + { label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" }, + { label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" }, + { label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" }, + { label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" }, + { label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" }, + { label: "Elterndienst", type: "elterndienst", color: "oklch(0.60 0.15 160)" }, + { label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" }, +]; + +export function CalendarOverviewClient({ + termine, + userId, + isAdmin, +}: { + termine: CalendarListItemDto[]; + userId: string; + isAdmin: boolean; +}) { + const [view, setView] = useState<"liste" | "monat" | "agenda">("liste"); + const [searchQuery, setSearchQuery] = useState(""); + const [activeTypes, setActiveTypes] = useState>(new Set()); + + // Month navigation state + const [currentMonthDate, setCurrentMonthDate] = useState(new Date()); + + // Dialog state for day clicking in Month view + const [selectedDay, setSelectedDay] = useState(null); + + // Delete event states + const [deleteEventId, setDeleteEventId] = useState(null); + const [deleteEventTitle, setDeleteEventTitle] = useState(""); + const [isDeleting, startDeleteTransition] = useTransition(); + + const toggleType = (type: string) => { + setActiveTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }; + + // Filter logic + const filteredEvents = useMemo(() => { + return termine.filter((item) => { + // 1. Search Query filter + const query = searchQuery.trim().toLowerCase(); + if (query) { + const titleMatch = item.title.toLowerCase().includes(query); + const descMatch = + item.kind === "termin" && item.description + ? item.description.toLowerCase().includes(query) + : false; + const familyMatch = + item.kind === "duty" && item.familyName + ? item.familyName.toLowerCase().includes(query) + : false; + if (!titleMatch && !descMatch && !familyMatch) return false; + } + + // 2. Category filter + if (activeTypes.size > 0) { + const itemType = item.kind === "duty" ? "elterndienst" : item.type; + if (!activeTypes.has(itemType)) return false; + } + + return true; + }); + }, [termine, searchQuery, activeTypes]); + + // Check if an event falls on a specific day + const isEventOnDay = (item: CalendarListItemDto, day: Date) => { + const eventStart = new Date(item.startDate); + const eventEnd = new Date(item.endDate); + const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime(); + const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime(); + const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime(); + return dayTime >= startTime && dayTime <= endTime; + }; + + // Get events for selected day (using all filtered events or all events? We use filtered to keep visual filters consistent!) + const selectedDayEvents = useMemo(() => { + if (!selectedDay) return []; + return filteredEvents.filter((item) => isEventOnDay(item, selectedDay)); + }, [selectedDay, filteredEvents]); + + // Handle event delete + const handleDeleteConfirm = () => { + if (!deleteEventId) return; + startDeleteTransition(async () => { + const res = await deleteTerminAdmin(deleteEventId); + if (res.error) { + toast.error(res.error); + } else { + toast.success("Termin wurde erfolgreich gelöscht."); + setDeleteEventId(null); + // If deleting from the day dialog, update selectedDay if it becomes empty or keep it open + } + }); + }; + + return ( +
+ {/* Search and Filters Header */} +
+
+ {(["liste", "monat", "agenda"] as const).map((v) => ( + + ))} +
+ +
+ {/* Category chips */} +
+ + + Filter + + {categories.map((cat) => { + const isActive = activeTypes.has(cat.type); + return ( + + ); + })} +
+ +
+ + setSearchQuery(e.target.value)} + /> +
+
+
+ + {/* Main View Contents */} + {view === "liste" && ( + + )} + + {view === "monat" && ( + setSelectedDay(day)} + /> + )} + + {view === "agenda" && ( + { + setDeleteEventId(id); + setDeleteEventTitle(title); + }} + /> + )} + + {/* Day details Dialog for Month View */} + !open && setSelectedDay(null)}> + + + + {selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })} + + + Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag. + + + +
+ {selectedDayEvents.length === 0 ? ( +

+ Keine Termine oder Dienste an diesem Tag. +

+ ) : ( + selectedDayEvents.map((item) => ( +
+ {item.kind === "duty" ? ( +
+
+ +
+
+
+

+ {item.title} +

+ + Elterndienst + +
+

+ Familie: {item.familyName} +

+

+ + {format(new Date(item.startDate), "dd.MM.")} -{" "} + {format(new Date(item.endDate), "dd.MM.yyyy")} +

+ {isAdmin && ( + + )} +
+
+ ) : ( +
+
+
+
+

+ {item.title} +

+ + {getTypeLabel(item.type)} + +
+ +

+ + {item.allDay + ? "Ganztägig" + : `${format(new Date(item.startDate), "HH:mm")} - ${format( + new Date(item.endDate), + "HH:mm", + )}`} +

+ + {item.description && ( +

+ {item.description} +

+ )} + + {item.participantInfo && item.showParticipantInfo && ( +
+
+ + Hinweis für Teilnehmer +
+

{item.participantInfo}

+
+ )} + + {item.mitbringselListEnabled && ( +
+ +
+ )} + + {isAdmin && ( +
+ + +
+ )} +
+
+ )} +
+ )) + )} +
+ +
+ + {/* Delete Confirmation Dialog */} + !open && setDeleteEventId(null)}> + + + Termin unwiderruflich löschen? + + Möchtest du den Termin “{deleteEventTitle}” wirklich löschen? Alle + zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren. + + + + + + + + +
+ ); +} + +// ===================================================================== +// Month Grid Component +// ===================================================================== +function MonthView({ + currentMonthDate, + setCurrentMonthDate, + events, + isEventOnDay, + onDayClick, +}: { + currentMonthDate: Date; + setCurrentMonthDate: (date: Date) => void; + events: CalendarListItemDto[]; + isEventOnDay: (item: CalendarListItemDto, day: Date) => boolean; + onDayClick: (day: Date) => void; +}) { + const monthStart = startOfMonth(currentMonthDate); + const monthEnd = endOfMonth(monthStart); + const startDate = startOfWeek(monthStart, { weekStartsOn: 1 }); + const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 }); + + const days = useMemo(() => { + return eachDayOfInterval({ start: startDate, end: endDate }); + }, [startDate, endDate]); + + const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + + return ( + +
+

+ {format(currentMonthDate, "MMMM yyyy", { locale: de })} +

+
+ + + +
+
+ +
+ {weekdays.map((day) => ( +
{day}
+ ))} +
+ +
+ {days.map((day, idx) => { + const isCurrentMonth = isSameMonth(day, currentMonthDate); + const isToday = isSameDay(day, new Date()); + const dayEvents = events.filter((item) => isEventOnDay(item, day)); + + return ( +
onDayClick(day)} + className={[ + "min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between", + !isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]", + ].join(" ")} + > + {/* Day number cell top */} +
+ + {format(day, "d")} + + {dayEvents.length > 0 && ( + + {dayEvents.length} {dayEvents.length === 1 ? "Eintrag" : "Einträge"} + + )} +
+ + {/* Event indicators/snippets */} +
+ {dayEvents.slice(0, 3).map((item) => { + const color = + item.kind === "duty" + ? "oklch(0.60 0.15 160)" + : getCategoryColor(item.type); + return ( +
+ + + {item.title} + +
+ ); + })} + {dayEvents.length > 3 && ( +
+ + {dayEvents.length - 3} weitere +
+ )} +
+
+ ); + })} +
+
+ ); +} + +// ===================================================================== +// Agenda Timeline View Component +// ===================================================================== +function AgendaView({ + events, + isAdmin, + userId, + onDeleteTermin, +}: { + events: CalendarListItemDto[]; + isAdmin: boolean; + userId: string; + onDeleteTermin: (id: string, title: string) => void; +}) { + const grouped = useMemo(() => { + const groups = new Map(); + + events.forEach((item) => { + const key = format(new Date(item.startDate), "yyyy-MM-dd"); + const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] }; + group.items.push(item); + groups.set(key, group); + }); + + return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime()); + }, [events]); + + if (events.length === 0) { + return ( + + + +

+ Keine Termine oder Dienste gefunden +

+

+ Passe deine Filter an oder suche nach einem anderen Begriff. +

+
+
+ ); + } + + return ( +
+ {grouped.map((group) => ( +
+ {/* Timeline marker */} +
+ + {/* Date Label on left */} +
+
+ {format(group.date, "d. MMMM", { locale: de })} +
+
+ {format(group.date, "EEEE", { locale: de })} +
+
+ + {/* Items stack on right */} +
+ {group.items.map((item) => { + const isTermin = item.kind === "termin"; + const color = isTermin ? getCategoryColor(item.type) : "oklch(0.60 0.15 160)"; + + return ( +
+
+
+
+
+

+ {item.title} +

+ {isTermin ? ( + + {getTypeLabel(item.type)} + + ) : ( + + Elterndienst + + )} + {isTermin && item.status === TerminStatus.PENDING && ( + Ausstehend + )} +
+ +

+ + {isTermin + ? item.allDay + ? "Ganztägig" + : `${format(new Date(item.startDate), "HH:mm")} - ${format( + new Date(item.endDate), + "HH:mm", + )}` + : `${format(new Date(item.startDate), "dd.MM.")} - ${format( + new Date(item.endDate), + "dd.MM.yyyy", + )}`} + {isTermin && } + {isTermin ? "Kita" : `Familie: ${item.familyName}`} +

+ + {isTermin && item.description && ( +

+ {item.description} +

+ )} +
+
+ + {/* Actions for admins */} + {isAdmin && isTermin && ( +
+ + +
+ )} +
+ ); + })} +
+
+ ))} +
+ ); +} + +// ===================================================================== +// Helper functions for categories/labels +// ===================================================================== +function getCategoryColor(type: TerminType): string { + switch (type) { + case TerminType.KITA_FEST: + case TerminType.MITMACH_TAG: + return "var(--good)"; + case TerminType.SCHLIESSTAG: + case TerminType.TEAMTAG: + return "var(--danger)"; + case TerminType.GEBURTSTAG_INTERN: + case TerminType.GEBURTSTAG_EXTERN: + return "oklch(0.50 0.10 320)"; + case TerminType.ELTERNABEND: + case TerminType.MITGLIEDERVERSAMMLUNG: + case TerminType.ELTERNCAFE: + return "oklch(0.50 0.10 280)"; + default: + return "var(--ink-muted)"; + } +} + +function getTypeLabel(type: TerminType): string { + switch (type) { + case TerminType.KITA_FEST: + return "Kita-Fest"; + case TerminType.MITMACH_TAG: + return "Mitmach-Tag"; + case TerminType.SCHLIESSTAG: + return "Schließtag"; + case TerminType.TEAMTAG: + return "Teamtag"; + case TerminType.ELTERNABEND: + return "Elternabend"; + case TerminType.MITGLIEDERVERSAMMLUNG: + return "Mitgliederversammlung"; + case TerminType.ELTERNCAFE: + return "Elterncafe"; + case TerminType.GEBURTSTAG_INTERN: + return "Geburtstag"; + case TerminType.GEBURTSTAG_EXTERN: + return "Raumanfrage"; + default: + return "Sonstiges"; + } +} diff --git a/src/app/dashboard/kalender/_components/termin-list.tsx b/src/app/dashboard/kalender/_components/termin-list.tsx index 2e254e7..7fc3a82 100644 --- a/src/app/dashboard/kalender/_components/termin-list.tsx +++ b/src/app/dashboard/kalender/_components/termin-list.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useTransition } from "react"; import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client"; import { format } from "date-fns"; import { de } from "date-fns/locale"; @@ -10,7 +11,9 @@ import { MapPin, MoreHorizontal, WashingMachine, + Trash2, } from "lucide-react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -22,9 +25,12 @@ import { DialogHeader, DialogTitle, DialogTrigger, + DialogFooter, } from "@/components/ui/dialog"; import { AdminTerminModal } from "./admin-termin-modal"; import { TerminRequestModal } from "./termin-request-modal"; +import { MitbringselList } from "./mitbringsel-list"; +import { deleteTerminAdmin } from "../actions"; export type TerminListItemDto = { kind: "termin"; @@ -62,6 +68,7 @@ export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto; export function TerminList({ termine, + userId, isAdmin, }: { termine: CalendarListItemDto[]; @@ -115,9 +122,9 @@ export function TerminList({
{group.items.map((item) => item.kind === "duty" ? ( - + ) : ( - + ), )}
@@ -128,8 +135,9 @@ export function TerminList({ ); } -function DutyRow({ duty }: { duty: DutyCalendarItemDto }) { +function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) { const color = "var(--good)"; + const [menuOpen, setMenuOpen] = useState(false); return (
@@ -147,92 +155,224 @@ function DutyRow({ duty }: { duty: DutyCalendarItemDto }) { {duty.familyName}

-
+
Elterndienst - + {isAdmin ? ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> + + + )} +
+ ) : ( +
+ )}
); } - -function TerminRow({ termin }: { termin: TerminListItemDto }) { +function TerminRow({ + termin, + userId, + isAdmin, +}: { + termin: TerminListItemDto; + userId: string; + isAdmin: boolean; +}) { const color = getTypeColor(termin.type); + const [menuOpen, setMenuOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [isDeleting, startDeleteTransition] = useTransition(); + + const handleDeleteConfirm = () => { + startDeleteTransition(async () => { + const res = await deleteTerminAdmin(termin.id); + if (res.error) { + toast.error(res.error); + } else { + toast.success("Termin wurde erfolgreich gelöscht."); + setDeleteOpen(false); + } + }); + }; return ( - -
-
- -
-
-

- {termin.title} -

- {termin.status === TerminStatus.PENDING && ( - Ausstehend + <> + +
+
+ +
+
+

+ {termin.title} +

+ {termin.status === TerminStatus.PENDING && ( + Ausstehend + )} +
+

+ + {formatTerminTime(termin)} + + Kita +

+
+
+ {getTypeLabel(termin.type)} + + + + {isAdmin ? ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ + +
+ + )} +
+ ) : ( +
)}
-

- - {formatTerminTime(termin)} - - Kita -

-
- {getTypeLabel(termin.type)} - - - - -
-
- - - {termin.title} - - {format(termin.startDate, "PP", { locale: de })} - {!termin.allDay && - ` · ${format(termin.startDate, "p", { locale: de })} - ${format( - termin.endDate, - "p", - { locale: de }, - )}`} - - + + + {termin.title} + + {format(termin.startDate, "PP", { locale: de })} + {!termin.allDay && + ` · ${format(termin.startDate, "p", { locale: de })} - ${format( + termin.endDate, + "p", + { locale: de }, + )}`} + + -
- {getTypeLabel(termin.type)} - - {termin.description ? ( -

- {termin.description} -

- ) : ( -

- Keine Beschreibung hinterlegt. -

- )} - - {termin.participantInfo && termin.showParticipantInfo && ( -
-
- - Wichtiger Hinweis für Teilnehmer -
-

{termin.participantInfo}

+
+
+ {getTypeLabel(termin.type)}
- )} -
- -
+ + {termin.description ? ( +

+ {termin.description} +

+ ) : ( +

+ Keine Beschreibung hinterlegt. +

+ )} + + {termin.participantInfo && termin.showParticipantInfo && ( +
+
+ + Wichtiger Hinweis für Teilnehmer +
+

{termin.participantInfo}

+
+ )} + + {termin.mitbringselListEnabled && ( +
+ +
+ )} +
+ +
+ + {/* Delete Confirmation Dialog */} + + + + Termin unwiderruflich löschen? + + Möchtest du den Termin “{termin.title}” wirklich löschen? Alle + zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren. + + + + + + + + + ); } diff --git a/src/app/dashboard/kalender/actions.ts b/src/app/dashboard/kalender/actions.ts index bf04a08..5007614 100644 --- a/src/app/dashboard/kalender/actions.ts +++ b/src/app/dashboard/kalender/actions.ts @@ -335,3 +335,28 @@ export async function deleteMitbringsel(itemId: string) { return { error: "Ein Fehler ist aufgetreten." }; } } + +export async function deleteTerminAdmin(terminId: string) { + const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); + const kitaId = session.user.kitaId; + if (!kitaId) { + return { error: "Kein Mandant zugeordnet." }; + } + + try { + await prisma.termin.delete({ + where: { + id: terminId, + kitaId, + }, + }); + + revalidatePath("/dashboard/kalender"); + revalidatePath("/dashboard/admin/kalender"); + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + console.error("Fehler beim Löschen des Termins:", error); + return { error: "Termin konnte nicht gelöscht werden." }; + } +} diff --git a/src/app/dashboard/kalender/page.tsx b/src/app/dashboard/kalender/page.tsx index c3c7343..b06e081 100644 --- a/src/app/dashboard/kalender/page.tsx +++ b/src/app/dashboard/kalender/page.tsx @@ -5,15 +5,15 @@ import { TerminType, UserRole, } from "@prisma/client"; -import { HelpCircle, ListFilter, Search } from "lucide-react"; +import { HelpCircle } from "lucide-react"; import { requireKitaSession } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; -import { Input } from "@/components/ui/input"; import { AdminTerminModal } from "./_components/admin-termin-modal"; import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen"; -import { TerminList, type CalendarListItemDto } from "./_components/termin-list"; +import { type CalendarListItemDto } from "./_components/termin-list"; import { TerminRequestModal } from "./_components/termin-request-modal"; +import { CalendarOverviewClient } from "./_components/calendar-overview-client"; export const metadata = { title: "Terminkalender · Kita-Planer" }; @@ -253,7 +253,7 @@ export default async function KalenderPage({
{currentTab === "übersicht" ? ( - ) : ( -
) : ( - -
-
- {["Liste", "Monat", "Agenda"].map((label, index) => ( - - ))} -
-
- -
- - -
-
-
- - -
- ); -} function TabBar({ currentTab, @@ -400,50 +358,4 @@ function TabLink({ ); } -function CategoryChips() { - const categories = [ - ["Schließtag", TerminType.SCHLIESSTAG], - ["Teamtag", TerminType.TEAMTAG], - ["Elternabend", TerminType.ELTERNABEND], - ["Fest", TerminType.KITA_FEST], - ["Geburtstag", TerminType.GEBURTSTAG_INTERN], - ["Sonstiges", TerminType.SONSTIGES], - ] as const; - return ( -
- - - Filter - - {categories.map(([label, type]) => ( - - - {label} - - ))} -
- ); -} - -function getCategoryColor(type: TerminType) { - switch (type) { - case TerminType.SCHLIESSTAG: - case TerminType.TEAMTAG: - return "var(--danger)"; - case TerminType.ELTERNABEND: - return "oklch(0.50 0.10 280)"; - case TerminType.KITA_FEST: - return "var(--good)"; - case TerminType.GEBURTSTAG_INTERN: - return "oklch(0.50 0.10 320)"; - default: - return "var(--ink-muted)"; - } -} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 02872b6..e44f15c 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -37,8 +37,8 @@ export default async function DashboardLayout({ return (
- {/* ── Sidebar ───────────────────────────────────────────────────── */} -
@@ -214,6 +241,8 @@ export default async function PlanungsZentralePage() { coverageFactor={coverageFactor} closureCount={closureCount} monthName={monthName} + year={targetYear} + month={targetMonth} />
); diff --git a/src/app/dashboard/notdienst/plan/plan-view.tsx b/src/app/dashboard/notdienst/plan/plan-view.tsx index ec32937..cf78010 100644 --- a/src/app/dashboard/notdienst/plan/plan-view.tsx +++ b/src/app/dashboard/notdienst/plan/plan-view.tsx @@ -18,10 +18,20 @@ 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 { @@ -54,6 +64,8 @@ interface PlanViewProps { coverageFactor: number; closureCount: number; monthName: string; + year: number; + month: number; } export function PlanView({ @@ -70,16 +82,19 @@ export function PlanView({ 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(); + const result = await generatePlanAction(year, month); if ("error" in result && result.error) { toast.error(result.error as string); } else { @@ -100,6 +115,30 @@ export function PlanView({ }); }; + const handleSendReminders = () => { + startTransition(async () => { + const result = await sendNotdienstRemindersAction(year, month); + if ("error" in result && result.error) { + toast.error(result.error as string); + } else if (result.success) { + toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`); + } + }); + }; + + 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") return; const valueToSet = newChildId === "empty" ? null : newChildId; @@ -132,8 +171,12 @@ export function PlanView({ -
@@ -199,8 +242,16 @@ export function PlanView({
-
@@ -282,7 +333,7 @@ export function PlanView({ return (
-
+
)} -
- +
{isDraft ? "Entwurf" : "Veröffentlicht"} @@ -347,6 +400,35 @@ export function PlanView({ onAssignmentChange={handleAssignmentChange} /> )} + + + + + Plan zurücksetzen? + + Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren. + + + + + + + +
); } diff --git a/src/emails/NotdienstAvailabilityReminderEmail.tsx b/src/emails/NotdienstAvailabilityReminderEmail.tsx new file mode 100644 index 0000000..30edeb5 --- /dev/null +++ b/src/emails/NotdienstAvailabilityReminderEmail.tsx @@ -0,0 +1,166 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from "@react-email/components"; + +import { baseBodyStyle, colors, fontStack } from "./_styles"; + +type NotdienstAvailabilityReminderEmailProps = { + parentName: string; + kitaName: string; + monthLabel: string; + link: string; +}; + +export function NotdienstAvailabilityReminderEmail({ + parentName, + kitaName, + monthLabel, + link, +}: NotdienstAvailabilityReminderEmailProps) { + return ( + + + Erinnerung: Verfügbarkeiten für den Notdienst {monthLabel} eintragen + + +
+
+ Kita-Planer +
+ Erinnerung Notdienst-Planung + Verfügbarkeiten für {monthLabel} eintragen + + Hallo {parentName}, + + + + für den Monat {monthLabel} läuft aktuell die Planung des Notdienstes in der Kita {kitaName}. + Bitte trage deine Verfügbarkeiten im Planer ein, damit wir die Tage fair verteilen können. + + + + + + Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser: + + {link} +
+ + + Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet. + +
+ + + ); +} + +const styles = { + body: baseBodyStyle, + container: { + width: "100%", + maxWidth: "600px", + margin: "0 auto", + padding: "32px 20px", + fontFamily: fontStack, + }, + card: { + padding: "0 28px 28px", + backgroundColor: colors.cardBg, + borderRadius: "10px", + border: `1px solid ${colors.border}`, + boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)", + overflow: "hidden", + fontFamily: fontStack, + }, + brandBar: { + margin: "0 -28px 28px", + padding: "14px 28px", + backgroundColor: colors.brandGreen, + fontFamily: fontStack, + }, + brand: { + margin: 0, + color: colors.eyebrow, + fontSize: "12px", + fontWeight: 800, + letterSpacing: "0.08em", + textTransform: "uppercase" as const, + fontFamily: fontStack, + }, + kicker: { + margin: "0 0 10px", + color: colors.brandGreenAccent, + fontSize: "12px", + fontWeight: 800, + letterSpacing: "0.06em", + textTransform: "uppercase" as const, + fontFamily: fontStack, + }, + heading: { + margin: "0 0 14px", + color: colors.textPrimary, + fontSize: "28px", + lineHeight: "34px", + fontWeight: 800, + fontFamily: fontStack, + }, + lead: { + margin: "0 0 22px", + color: colors.textBody, + fontSize: "16px", + lineHeight: "24px", + fontFamily: fontStack, + }, + text: { + margin: "0 0 24px", + color: colors.textBody, + fontSize: "15px", + lineHeight: "24px", + fontFamily: fontStack, + }, + button: { + display: "inline-block", + padding: "14px 20px", + backgroundColor: colors.brandGreen, + color: "#ffffff", + borderRadius: "6px", + fontSize: "15px", + fontWeight: 700, + textDecoration: "none", + fontFamily: fontStack, + }, + fallbackText: { + margin: "28px 0 8px", + color: colors.textMuted, + fontSize: "13px", + lineHeight: "20px", + fontFamily: fontStack, + }, + linkText: { + margin: 0, + color: colors.link, + fontSize: "13px", + lineHeight: "20px", + wordBreak: "break-all" as const, + fontFamily: fontStack, + }, + footer: { + margin: "20px 0 0", + color: colors.textFaint, + fontSize: "12px", + lineHeight: "18px", + textAlign: "center" as const, + fontFamily: fontStack, + }, +};