Compare commits

...

17 Commits

Author SHA1 Message Date
t.indorf 4cd16773c1 Merge branch 'codex/live-testplan-doc' 2026-05-23 12:47:44 +02:00
t.indorf 74dc2d6b57 Merge branch 'codex/notdienst-plan-fix'
# Conflicts:
#	src/app/dashboard/notdienst/plan/plan-view.tsx
2026-05-23 12:47:35 +02:00
t.indorf 27c5a6940a feat: implement premium responsive mobile shell and drawer 2026-05-22 15:50:03 +02:00
t.indorf f28956194c feat: add directory opt-out sync and layout/xss seed data extensions 2026-05-22 15:22:12 +02:00
t.indorf e9595fab21 feat: show family assignment counts in notdienst planning dropdown and support editing unassigned days in published plans 2026-05-21 14:29:13 +02:00
t.indorf f6acb3d271 feat: default to Month view and implement household services filter toggle for parents 2026-05-21 13:24:42 +02:00
t.indorf 339563bf4d feat: display published Notdienst assignments in appointment calendar with custom styling for own assignments 2026-05-21 13:04:52 +02:00
t.indorf 9915c8213e fix(notdienst): resolve timezone offset mismatch in plan generation and fix linter warning in calendar modal 2026-05-21 12:53:46 +02:00
t.indorf 7eaa198a29 fix(notdienst): resolve timezone offset mismatch in plan generation 2026-05-21 12:41:18 +02:00
t.indorf cf3fb82afe Mark all TODOs as completed in UI-TODO.md 2026-05-21 11:41:50 +02:00
t.indorf 88f9c156fc Merge remote-tracking branch 'origin/main' into codex/notdienst-month-settings 2026-05-21 10:47:14 +02:00
t.indorf ab7432f3a3 Merge branch 'codex/replace-alert-dialogs' into codex/notdienst-month-settings 2026-05-21 10:46:31 +02:00
t.indorf 84aae1d72e Implement missing UI button features, filters, search, and view toggles across all modules 2026-05-21 10:44:23 +02:00
t.indorf beffba65c5 Document UI no-op follow-ups 2026-05-20 23:51:10 +02:00
t.indorf ec2e0b01e9 Replace native confirms with dialogs 2026-05-20 23:49:42 +02:00
t.indorf 89364e9011 Replace native confirms with dialogs 2026-05-20 23:48:40 +02:00
t.indorf 5dca7c76f8 testplan 2026-05-17 23:55:35 +02:00
34 changed files with 4560 additions and 1107 deletions
+79
View File
@@ -0,0 +1,79 @@
# UI-ToDo: klickbare Elemente ohne Funktion
Stand: 2026-05-20
Geprüft lokal als Admin mit Demo-Login `admin@waldameisen.local`.
## Notdienst-Planung
- [x] `src/app/dashboard/notdienst/plan/page.tsx:186`
Monat zurück/vor haben keinen Handler oder Link.
- [x] `src/app/dashboard/notdienst/plan/page.tsx:196`
`Einstellungen` ist klickbar, öffnet aber nichts und navigiert nicht.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:135`
`Erinnerungen verschicken` im Empty-State ist ohne Funktion.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:202`
`Erinnerung an offene senden (...)` ist ohne Funktion.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:312`
`Als PDF exportieren` ist ohne Funktion.
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:316`
`Plan zurücksetzen` ist ohne Funktion.
## Terminkalender
- [x] `src/app/dashboard/kalender/page.tsx:296`
Umschalter `Liste / Monat / Agenda` ändern keine Ansicht.
- [x] `src/app/dashboard/kalender/page.tsx:317`
Suche `Termine suchen` filtert aktuell nicht.
- [x] `src/app/dashboard/kalender/page.tsx:415`
Kategorie-Filterchips sind nur visuell.
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:152`
`Mehr Optionen` bei Elterndiensten ist ohne Funktion.
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:191`
`Mehr Optionen` bei Terminen ist ohne Funktion.
## Adressbuch
- [x] `src/app/dashboard/adressbuch/page.tsx:161`
`Sichtbarkeit` ist ohne Funktion.
- [x] `src/app/dashboard/adressbuch/page.tsx:193`
Suche `Familien, Namen oder E-Mail suchen` filtert aktuell nicht.
- [x] `src/app/dashboard/adressbuch/page.tsx:201`
Filterchips `Alle Gruppen`, `Mit Telefon`, `Mit Ämtern`, `Nur Opt-in` sind nur visuell.
- [x] `src/app/dashboard/adressbuch/page.tsx:334`
`Kontakt öffnen` ist ohne Funktion.
## Familienverwaltung
- [x] `src/app/dashboard/families/page.tsx:117`
`Ansicht` ist ohne Funktion.
- [x] `src/app/dashboard/families/page.tsx:133`
Suche `Familien, Eltern oder Kinder suchen` filtert aktuell nicht.
## Abwesenheiten Admin
- [x] `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
+85
View File
@@ -0,0 +1,85 @@
# Live-Testplan mein-kitaplaner.de
## Kopfbereich
- Zielsystem: `https://mein-kitaplaner.de`
- Testdatum: `2026-05-17`
- Strategie: echte Testdaten auf Produktion verwenden und anschließend bereinigen
- Testkennzeichnung: alle Testnamen und Test-E-Mails mit `Codex Live-Test` kennzeichnen
- Statuslegende:
- `[ ]` offen
- `[x]` erledigt
- `[!]` Blocker / Beobachtung
## Testdaten
- Gründer/Admin: `hanivan790@acanok.com`
- Test-Kita: `Codex Live-Test Kita-Verein 20260517-2343`
- Elternteil: `kitatester1233@mailaddress.de`
- Passwort: `<TEST_PASSWORD>`
Keine echten Passwörter, Secrets, Session-Werte, Tokens oder Invite-Links in diese Datei schreiben.
## Core Journey
- [x] Landingpage erreichbar.
- Erwartung: `https://mein-kitaplaner.de` lädt mit aktuellem Landingpage-Content.
- [x] Demo-CTA führt zum Kontaktformular.
- Erwartung: CTA öffnet `/#kontakt` und das Formular ist sichtbar.
- [x] Kontaktformular versendet Bestätigungsmail.
- Bestätigt: Testmail an `nolip18234@acanok.com` ist angekommen.
- [x] Registrierung: neuer Gründer/Admin kann Account erstellen.
- Testdaten: eindeutige Admin-Testmail und `<TEST_PASSWORD>`.
- Erwartung: Account wird erstellt, keine Fehlermeldung.
- [x] Redirect nach Registrierung führt zu `/onboarding`.
- Erwartung: Gründer landet nach Registrierung direkt im Onboarding.
- [x] Onboarding: Kita wird mit Standardmodulen angelegt.
- Testdaten: `Codex Live-Test Kita-Verein YYYYMMDD-HHMM`.
- Erwartung: Notdienst, Kalender und Adressbuch bleiben standardmäßig aktiv.
- [x] Redirect nach Onboarding führt zu `/dashboard`.
- Erwartung: Dashboard lädt ohne erneute Anmeldung.
- [x] Dashboard zeigt Kita-Namen und Admin-Navigation.
- Erwartung: Sidebar zeigt Test-Kita sowie Admin-Menüpunkte wie Familienverwaltung, Dienstplan, Schwarzes Brett und ErzieherInnen.
- [x] Familienverwaltung: Familie mit Kind und Elternteil anlegen.
- Testdaten: Familie, Elternteil und Kind mit `Codex Live-Test` kennzeichnen.
- Erwartung: Familie erscheint in der Liste.
- [ ] Einladung wird per E-Mail versendet.
- Erwartung: Eltern-Testadresse erhält eine Einladung.
- Erst als erledigt markieren, wenn die Mail im Postfach bestätigt wurde.
- [ ] Invite-Link öffnet Aktivierungsseite.
- Erwartung: Seite `/invite/[token]` zeigt personalisierte Einladung und Kita-Namen.
- Invite-Link nicht in diese Datei schreiben.
- [ ] Invite-Validierung: Passwort-Pflicht, Passwort-Mismatch, Datenschutz-Pflicht.
- Erwartung: fehlende oder unterschiedliche Passwörter und fehlende Datenschutzerklärung erzeugen sichtbare Fehler.
- [ ] Elternteil kann Einladung annehmen und wird eingeloggt.
- Erwartung: Passwort setzen, Datenschutzerklärung akzeptieren, optional Adressbuch-Opt-in wählen, danach Redirect zu `/dashboard`.
- [ ] Elternrolle sieht nur erlaubte Navigation.
- Erwartung: Eltern sehen keine Admin-only Punkte wie Familienverwaltung, Dienstplan, Schwarzes Brett-Admin, ErzieherInnen.
- [ ] Logout/Login für Admin und Elternteil funktionieren.
- Erwartung: beide Testaccounts können sich abmelden und mit `<TEST_PASSWORD>` erneut anmelden.
- [x] Zugriffsschutz: `/dashboard` ohne Session leitet zu `/login`.
- Erwartung: ausgeloggter Aufruf von `/dashboard` führt zu `/login?callbackUrl=%2Fdashboard` oder entsprechender Login-Route.
- [ ] Cleanup: Test-Kita/User/Familie über Superadmin löschen.
- Erwartung: Test-Kita wird über Superadmin-Diagnose mit exakter Namensbestätigung gelöscht.
- Erst als erledigt markieren, wenn Superadmin-Ansicht keine Test-Kita/Test-User mehr zeigt.
- [ ] Nach Cleanup: Test-Login funktioniert nicht mehr / Daten nicht sichtbar.
- Erwartung: Admin- und Eltern-Testlogin schlagen fehl; Test-Kita erscheint nicht mehr.
## Beobachtungen
- [x] `2026-05-17`: Kontaktformular auf Produktion getestet. Bestätigungsmail an `nolip18234@acanok.com` ist angekommen.
- [x] `2026-05-17`: Registrierung mit `hanivan790@acanok.com`, Redirect nach `/onboarding`, Onboarding mit `Codex Live-Test Kita-Verein 20260517-2343` und Dashboard/Admin-Navigation erfolgreich geprüft.
- [!] `2026-05-17`: Familienanlage mit Elternadresse `nolip18234@acanok.com` blockiert, weil die E-Mail-Adresse bereits existiert. Mit `kitatester1233@mailaddress.de` erfolgreich fortgesetzt.
- [x] `2026-05-17`: Admin-Logout, Zugriffsschutz für `/dashboard` ohne Session und Admin-Login mit temporärem Passwort erfolgreich geprüft. Kombinierter Login-Check bleibt offen, bis ein Elternaccount aktiviert werden kann.
- [x] `2026-05-17`: Familie `Codex Live-Test Familie 20260517-2343` mit Kind und Elternteil `kitatester1233@mailaddress.de` erfolgreich angelegt; Status in der Familienverwaltung: `Einladung offen`.
- [ ] `2026-05-17`: Im Familien-Details-Dialog ist kein Invite-Link sichtbar; Fortsetzung der Invite-Tests benötigt Mailbestätigung und Invite-Link aus dem Postfach.
- [ ] Mailzeit Einladung Elternteil: offen.
- [ ] Cleanup-Ergebnis: offen.
## Pflege-Regeln
- Erledigte Schritte während des Tests direkt von `[ ]` auf `[x]` setzen.
- Blocker mit `[!]` markieren und kurz im Abschnitt Beobachtungen beschreiben.
- Mailversand nur als erledigt markieren, wenn die Mail im jeweiligen Postfach bestätigt wurde.
- Keine echten Passwörter, Secrets, Tokens oder vollständigen Invite-Links dokumentieren.
- Cleanup erst als erledigt markieren, wenn die Testdaten in der Superadmin-Ansicht nicht mehr sichtbar sind.
+53
View File
@@ -659,6 +659,24 @@ async function createAnnouncements(context: SeedContext) {
},
});
const longTitle = await prisma.announcement.create({
data: {
kitaId: kita.id,
title: "Dies ist eine extrem lange Ankuendigung mit einem superlangen Titel um das Layout des Dashboards und des Schwarzen Bretts auf korrekten Textumbruch und Mobilfreundlichkeit zu testen",
content: "Inhalt der superlangen Ankuendigung fuer Layout-Testing.",
authorId: admin.id,
},
});
const xssTitle = await prisma.announcement.create({
data: {
kitaId: kita.id,
title: "Boesartiger Titel mit <script>alert('XSS_NEWS_TITLE')</script> HTML Tags",
content: "Dieser Beitrag testet, ob die Anwendung XSS-Angriffe im Titel oder im Inhalt filtert. <script>alert('XSS_NEWS_CONTENT')</script>",
authorId: admin.id,
},
});
await prisma.attachment.create({
data: {
announcementId: sommerfest.id,
@@ -808,6 +826,41 @@ async function createTermine(
},
});
const highDensityDay = addDays(now, 7);
for (let i = 1; i <= 8; i++) {
await prisma.termin.create({
data: {
kitaId: kita.id,
title: `Parallel-Termin #${i}`,
description: `Beschreibung fuer Parallel-Termin #${i}`,
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate: highDensityDay,
endDate: highDensityDay,
allDay: false,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
}
await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Boesartiger Termin <script>alert('XSS_CAL_TITLE')</script>",
description: "XSS Test im Terminkalender <script>alert('XSS_CAL_DESC')</script>",
type: TerminType.SONSTIGES,
status: TerminStatus.CONFIRMED,
startDate: addDays(now, 5),
endDate: addDays(now, 5),
allDay: true,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
await prisma.mitbringselItem.createMany({
data: [
{
@@ -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<Partial<Record<AbsenceReason, number>>>((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 (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{filteredAbsences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - filteredAbsences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<button
onClick={() => handleDateNavigate(addDays(selectedDate, -1))}
className="inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</button>
<button
onClick={() => handleDateNavigate(today)}
className={`inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)] ${
isToday ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : ""
}`}
>
Heute
</button>
<button
onClick={() => handleDateNavigate(addDays(selectedDate, 1))}
className="inline-flex h-10 items-center gap-2 px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
<form onSubmit={handleDateFormSubmit} className="flex items-center gap-2">
<Input
type="date"
name="date"
value={inputDate}
onChange={(e) => setInputDate(e.target.value)}
className="w-[140px]"
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{/* Group Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsGroupOpen(!isGroupOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedGroup !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedGroup === "all" ? "Alle Gruppen" : selectedGroup}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isGroupOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsGroupOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedGroup("all");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gruppen
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Krippe");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Krippe"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krippe (Mock)
</button>
<button
type="button"
onClick={() => {
setSelectedGroup("Elementar");
setIsGroupOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedGroup === "Elementar"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Elementar (Mock)
</button>
</div>
</>
)}
</div>
{/* Reason Dropdown Filter */}
<div className="relative">
<button
type="button"
onClick={() => setIsReasonOpen(!isReasonOpen)}
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
selectedReason !== "all"
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
{selectedReason === "all" ? "Alle Gründe" : reasonLabel(selectedReason)}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{isReasonOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsReasonOpen(false)} />
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
<button
type="button"
onClick={() => {
setSelectedReason("all");
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === "all"
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Alle Gründe
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.ILLNESS);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.ILLNESS
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Krankheit
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.VACATION);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.VACATION
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Urlaub
</button>
<button
type="button"
onClick={() => {
setSelectedReason(AbsenceReason.OTHER);
setIsReasonOpen(false);
}}
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
selectedReason === AbsenceReason.OTHER
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
}`}
>
Sonstiges
</button>
</div>
</>
)}
</div>
</div>
{filteredAbsences.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.ILLNESS) }}
/>
{reasonLabel(AbsenceReason.ILLNESS)} {countsByReason[AbsenceReason.ILLNESS] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.OTHER) }}
/>
{reasonLabel(AbsenceReason.OTHER)} {countsByReason[AbsenceReason.OTHER] ?? 0}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(AbsenceReason.VACATION) }}
/>
{reasonLabel(AbsenceReason.VACATION)} {countsByReason[AbsenceReason.VACATION] ?? 0}
</span>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{error ? (
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{error}
</CardContent>
</Card>
) : filteredAbsences.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{filteredAbsences.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={filteredAbsences} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
@@ -0,0 +1,28 @@
import { AbsenceReason } from "@prisma/client";
export function reasonLabel(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "Krank";
if (reason === AbsenceReason.VACATION) return "Urlaub";
return "Sonstiges";
}
export function reasonColor(reason: AbsenceReason) {
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
if (reason === AbsenceReason.VACATION) return "var(--warn)";
return "var(--accent)";
}
export function formatDateRange(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
});
const endLabel = end.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
}
+16 -251
View File
@@ -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 (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TAGESÜBERSICHT
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Heute fehlen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
{absences.length} Kinder
</em>
</h1>
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{childCount - absences.length} anwesend
</span>
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<SegmentLink href={hrefForDate(addDays(selectedDate, -1))}>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück
</SegmentLink>
<SegmentLink href={hrefForDate(today)} active={isToday}>
Heute
</SegmentLink>
<SegmentLink href={hrefForDate(addDays(selectedDate, 1))}>
Vor
<ArrowRight className="h-3.5 w-3.5" />
</SegmentLink>
</div>
<form className="flex items-center gap-2">
<Input
type="date"
name="date"
defaultValue={dateKey}
className="w-[140px]"
<AbsencesClient
initialAbsences={tableItems}
childCount={childCount}
upcomingAbsenceCount={upcomingAbsenceCount}
closure={closure}
dateKey={dateKey}
selectedDateStr={selectedDate.toISOString()}
isToday={isToday}
isTooFarFuture={isTooFarFuture}
isClosedDay={isClosedDay}
todayStr={today.toISOString()}
error={result.error ?? null}
/>
<Button type="submit">Anzeigen</Button>
</form>
</div>
</header>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<FilterChip label="Alle Gruppen" />
<FilterChip label="Alle Gründe" />
</div>
{tableItems.length > 0 ? (
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
<ReasonInlineStat
reason={AbsenceReason.ILLNESS}
count={countsByReason[AbsenceReason.ILLNESS] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.OTHER}
count={countsByReason[AbsenceReason.OTHER] ?? 0}
/>
<ReasonInlineStat
reason={AbsenceReason.VACATION}
count={countsByReason[AbsenceReason.VACATION] ?? 0}
/>
<span>
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
</span>
</div>
) : null}
</div>
<div className="mt-4">
{result.error ? (
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
{result.error}
</CardContent>
</Card>
) : tableItems.length > 0 ? (
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle>
{tableItems.length} von {childCount} Kindern abwesend ·{" "}
{upcomingAbsenceCount} anstehend
</CardTitle>
<Badge variant="secondary">{dateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<AbsenceTable absences={tableItems} />
</CardContent>
</Card>
) : isTooFarFuture ? (
<EmptyState
tone="accent"
title="Noch nicht im Vorschauzeitraum"
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
/>
) : isClosedDay ? (
<EmptyState
tone="accent"
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
locale: de,
})}`}
description={
closure
? closure.title
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
}
/>
) : (
<EmptyState
tone="good"
title="Keine Abwesenheiten"
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
/>
)}
</div>
</div>
);
}
function SegmentLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={[
"inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition last:border-r-0 hover:bg-[var(--surface-2)] hover:text-[var(--ink)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
active ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : "",
].join(" ")}
>
{children}
</Link>
);
}
function FilterChip({ label }: { label: string }) {
return (
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-transparent px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
{label}
<ChevronDown className="h-3.5 w-3.5" />
</button>
);
}
function ReasonInlineStat({
reason,
count,
}: {
reason: AbsenceReason;
count: number;
}) {
return (
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: reasonColor(reason) }}
/>
{reasonLabel(reason)} {count}
</span>
);
}
function EmptyState({
tone,
title,
description,
}: {
tone: "good" | "accent";
title: string;
description: string;
}) {
const isGood = tone === "good";
return (
<Card>
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
<div
className={[
"flex h-12 w-12 items-center justify-center rounded-2xl",
isGood
? "bg-[var(--good-soft)] text-[var(--good)]"
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
].join(" ")}
>
{isGood ? (
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
) : (
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
)}
</div>
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
{title}
</h2>
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
{description}
</p>
<Link
href="/dashboard/admin/abwesenheiten"
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
Wochenübersicht öffnen
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</CardContent>
</Card>
);
}
@@ -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<Partial<Record<AbsenceReason, number>>>((acc, item) => {
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
return acc;
}, {});
}
@@ -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<Set<string>>(new Set());
const [selectedFamily, setSelectedFamily] = useState<DirectoryFamilyDto | null>(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<string, DirectoryFamilyDto[]>();
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 (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
{/* Header */}
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
KONTAKTE · ADRESSBUCH
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Freigegebene{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Kontakte
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{totalVisibleParentCount} sichtbar
</span>
<Button variant="ghost" size="sm" onClick={() => setPrivacyDialogOpen(true)}>
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Sichtbarkeit
</Button>
</div>
</header>
{/* Main Household List or Empty State */}
{initialFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Contact className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine freigegebenen Kontakte
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{/* Metrics & Search Bar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Haushalte" value={filteredFamilies.length} />
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
<DirectoryMetric label="Kinder" value={childCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Namen oder E-Mail suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Custom Filter Chips */}
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setActiveFilters(new Set())}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.size === 0
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Alle anzeigen
</button>
<button
type="button"
onClick={() => toggleFilter("telefon")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("telefon")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Telefon
</button>
<button
type="button"
onClick={() => toggleFilter("aemter")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("aemter")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Mit Ämtern
</button>
<button
type="button"
onClick={() => toggleFilter("optIn")}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
activeFilters.has("optIn")
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Haushalt komplett freigegeben
</button>
</div>
{/* Household list cards wrapper */}
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<SlidersHorizontal className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine passenden Kontakte gefunden
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche oder die Filterchips an, um Einträge zu sehen.
</p>
</CardContent>
</Card>
) : (
<Card className="overflow-hidden p-0">
{groupedFamilies.map((group) => (
<section key={group.initial}>
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
{group.initial}
</h2>
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
{group.families.length}
</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{group.families.map((family) => {
const visibleParents = family.parents.filter(
(parent) => parent.hasDirectoryOptIn
);
const duties = family.parents.flatMap((parent) => parent.duties);
return (
<div
key={family.id}
className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1"
>
{/* Left: Family Name & Children */}
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{family.name}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
{family.children.length === 0
? "Keine Kinder hinterlegt"
: family.children
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}
</p>
</div>
</div>
{/* Middle: Parents */}
<div className="grid gap-3">
{family.parents.map((user) => (
<div key={user.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
<p className="text-sm font-medium text-[var(--ink)]">
{user.firstName} {user.lastName}
</p>
{user.hasDirectoryOptIn ? (
<Badge variant="success">Opt-in</Badge>
) : (
<span className="text-xs italic text-[var(--ink-faint)]">
nicht freigegeben
</span>
)}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
{user.email && (
<a
href={`mailto:${user.email}`}
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{user.email}
</span>
</a>
)}
{user.phone ? (
<a
href={`tel:${user.phone}`}
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
{user.phone}
</a>
) : (
<span className="italic text-[var(--ink-faint)]">
Keine Telefonnummer
</span>
)}
</div>
)}
</div>
))}
</div>
{/* Right: Badges & Details Action */}
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
<Badge variant="secondary">
{visibleParents.length} von {family.parents.length} sichtbar
</Badge>
{duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
>
{duty.name}
</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFamily(family)}
>
Kontakt öffnen
</Button>
</div>
</div>
);
})}
</div>
</section>
))}
</Card>
)}
</div>
)}
{/* Household Detail Dialog */}
<Dialog
open={!!selectedFamily}
onOpenChange={(open) => !open && setSelectedFamily(null)}
>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
{selectedFamily && (
<>
<DialogHeader>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)] text-xl font-bold uppercase mb-2">
{selectedFamily.name.slice(0, 1)}
</div>
<DialogTitle className="text-2xl">Familie {selectedFamily.name}</DialogTitle>
<DialogDescription>
Detaillierte Kontaktdaten und Haushalts-Zugehörigkeiten.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-6">
{/* Children Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<Baby className="h-4 w-4" />
Kinder im Haushalt
</h4>
<div className="grid gap-2.5">
{selectedFamily.children.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)]">Keine Kinder hinterlegt.</p>
) : (
selectedFamily.children.map((child) => (
<div
key={child.id}
className="flex items-center justify-between rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3"
>
<div className="flex items-center gap-2">
<Baby className="h-4 w-4 text-[var(--accent-deep)]" />
<span className="font-semibold text-sm text-[var(--ink)]">
{child.firstName} {child.lastName}
</span>
</div>
{child.dateOfBirth && (
<span className="text-xs text-[var(--ink-soft)] font-medium">
Geboren: {format(new Date(child.dateOfBirth), "dd.MM.yyyy", { locale: de })}
</span>
)}
</div>
))
)}
</div>
</div>
{/* Parents/Users Details */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
<UserRound className="h-4 w-4" />
Eltern & Kontakte
</h4>
<div className="space-y-4">
{selectedFamily.parents.map((parent) => (
<div
key={parent.id}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
<div className="flex items-center justify-between gap-2 border-b border-[var(--hairline-soft)] pb-2 mb-3">
<span className="font-semibold text-[15px] text-[var(--ink)] flex items-center gap-1.5">
<UserRound className="h-4 w-4 text-[var(--ink-muted)]" />
{parent.firstName} {parent.lastName}
</span>
{parent.hasDirectoryOptIn ? (
<Badge variant="success">Freigegeben</Badge>
) : (
<Badge variant="secondary">Privat</Badge>
)}
</div>
{parent.hasDirectoryOptIn ? (
<div className="space-y-2.5 text-sm text-[var(--ink-soft)]">
{parent.email && (
<a
href={`mailto:${parent.email}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span className="break-all">{parent.email}</span>
</a>
)}
{parent.phone ? (
<a
href={`tel:${parent.phone}`}
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>{parent.phone}</span>
</a>
) : (
<div className="flex items-center gap-2.5 italic text-[var(--ink-faint)]">
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
<span>Keine Telefonnummer hinterlegt</span>
</div>
)}
{/* Address details */}
{(parent.street || parent.postalCode || parent.city) && (
<div className="flex items-start gap-2.5 border-t border-[var(--hairline-soft)] pt-2.5 mt-2.5">
<MapPin className="h-4 w-4 text-[var(--ink-muted)] shrink-0 mt-0.5" />
<div>
{parent.street && <p>{parent.street}</p>}
{(parent.postalCode || parent.city) && (
<p>
{parent.postalCode} {parent.city}
</p>
)}
</div>
</div>
)}
</div>
) : (
<p className="text-xs italic text-[var(--ink-faint)]">
Dieser Kontakt hat seine Daten nicht für das Adressbuch freigegeben.
</p>
)}
{parent.duties.length > 0 && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3 flex flex-wrap gap-1.5">
{parent.duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)] text-[10px]"
>
{duty.name}
</Badge>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
{/* Global Privacy settings dialog */}
<Dialog open={privacyDialogOpen} onOpenChange={setPrivacyDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-[var(--accent-deep)]" />
Sichtbarkeit & Datenschutz
</DialogTitle>
<DialogDescription className="mt-2 text-sm leading-6">
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.
<br />
<br />
Du kannst deine eigene Zustimmung sowie deine E-Mail-Adresse und Telefonnummer
jederzeit in deinen persönlichen Profileinstellungen einsehen und anpassen.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button variant="ghost" onClick={() => setPrivacyDialogOpen(false)}>
Schließen
</Button>
<a href="/dashboard/profil">
<Button>Zu den Profileinstellungen</Button>
</a>
</div>
</DialogContent>
</Dialog>
</div>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{value}</span>
{label}
</span>
);
}
+9 -274
View File
@@ -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 (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
KONTAKTE · ADRESSBUCH
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Freigegebene{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
Kontakte
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte und Kontaktdaten, die explizit für das interne
Kita-Adressbuch freigegeben wurden.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{visibleParentCount} sichtbar
</span>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Sichtbarkeit
</Button>
</div>
</header>
{families.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Contact className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine freigegebenen Kontakte
</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
erscheint die Familie hier.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Haushalte" value={families.length} />
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
<DirectoryMetric label="Kinder" value={childCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Namen oder E-Mail suchen"
className="pl-9"
/>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{["Alle Gruppen", "Mit Telefon", "Mit Ämtern", "Nur Opt-in"].map(
(filter, index) => (
<span
key={filter}
className={[
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium",
index === 0
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)]",
].join(" ")}
>
{filter}
</span>
),
)}
</div>
<Card className="overflow-hidden p-0">
{groupedFamilies.map((group) => (
<section key={group.initial}>
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
{group.initial}
</h2>
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
{group.families.length}
</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{group.families.map((family) => (
<FamilyDirectoryRow key={family.id} family={family} />
))}
</div>
</section>
))}
</Card>
</div>
)}
</div>
);
}
function FamilyDirectoryRow({ family }: { family: DirectoryFamilyDto }) {
const visibleParents = family.parents.filter(
(parent) => parent.hasDirectoryOptIn,
);
const duties = family.parents.flatMap((parent) => parent.duties);
return (
<div className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1">
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{family.name}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
{family.children.length === 0
? "Keine Kinder hinterlegt"
: family.children
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}
</p>
</div>
</div>
<div className="grid gap-3">
{family.parents.map((user) => (
<div key={user.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
<p className="text-sm font-medium text-[var(--ink)]">
{user.firstName} {user.lastName}
</p>
{user.hasDirectoryOptIn ? (
<Badge variant="success">Opt-in</Badge>
) : (
<span className="text-xs italic text-[var(--ink-faint)]">
nicht freigegeben
</span>
)}
</div>
{user.hasDirectoryOptIn && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
{user.email && (
<a
href={`mailto:${user.email}`}
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{user.email}
</span>
</a>
)}
{user.phone ? (
<a
href={`tel:${user.phone}`}
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
>
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
{user.phone}
</a>
) : (
<span className="italic text-[var(--ink-faint)]">
Keine Telefonnummer
</span>
)}
</div>
)}
</div>
))}
</div>
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
<Badge variant="secondary">
{visibleParents.length} von {family.parents.length} sichtbar
</Badge>
{duties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
>
{duty.name}
</Badge>
))}
</div>
<Button variant="ghost" size="sm">
Kontakt öffnen
</Button>
</div>
</div>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{value}</span>
{label}
</span>
);
}
function groupFamiliesByInitial(families: DirectoryFamilyDto[]) {
const groups = new Map<string, DirectoryFamilyDto[]>();
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 <DirectoryClient initialFamilies={families} />;
}
+15 -4
View File
@@ -1,10 +1,11 @@
"use client";
import { useTransition } from "react";
import { useState, useTransition } from "react";
import { Loader2, BellRing } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { triggerAlertAction } from "./actions";
export function AlertButton({
@@ -12,11 +13,10 @@ export function AlertButton({
}: {
assignmentId: string;
}) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleAlert = () => {
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
startTransition(async () => {
const result = await triggerAlertAction(assignmentId);
if ("error" in result && result.error) {
@@ -28,7 +28,17 @@ export function AlertButton({
};
return (
<Button variant="destructive" onClick={handleAlert} disabled={isPending}>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Notdienst-Alarm auslösen?"
description="Der Alarm informiert die hinterlegten Kontakte per E-Mail über den Notdienst."
confirmLabel="Alarm auslösen"
pendingLabel="Alarm wird ausgelöst..."
isPending={isPending}
onConfirm={handleAlert}
>
<Button variant="destructive" disabled={isPending}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -36,5 +46,6 @@ export function AlertButton({
)}
Alarm auslösen
</Button>
</ConfirmDialog>
);
}
@@ -5,6 +5,7 @@ import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Dialog,
DialogContent,
@@ -40,6 +41,9 @@ type EducatorDto = {
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
null,
);
const [isPending, startTransition] = useTransition();
const handleCreate = (formData: FormData) => {
@@ -79,14 +83,14 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
});
};
const handleDelete = (id: string) => {
if (!confirm("Wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) return;
const handleDelete = (educator: EducatorDto) => {
startTransition(async () => {
const res = await deleteEducator(id);
const res = await deleteEducator(educator.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("ErzieherIn gelöscht.");
setEducatorToDelete(null);
}
});
};
@@ -193,8 +197,9 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(ed.id)}
onClick={() => setEducatorToDelete(ed)}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
@@ -237,6 +242,25 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</form>
</DialogContent>
</Dialog>
<ConfirmDialog
open={!!educatorToDelete}
onOpenChange={(open) => {
if (!open) setEducatorToDelete(null);
}}
title="ErzieherIn löschen?"
description={
educatorToDelete
? `${educatorToDelete.firstName} ${educatorToDelete.lastName} wird aus dem Personalstamm entfernt.`
: "Dieser Eintrag wird aus dem Personalstamm entfernt."
}
confirmLabel="Löschen"
pendingLabel="Wird gelöscht..."
isPending={isPending}
onConfirm={() => {
if (educatorToDelete) handleDelete(educatorToDelete);
}}
/>
</div>
);
}
@@ -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 (
<div>
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
VERWALTUNG · FAMILIEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Familien{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
organisieren
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
className="flex items-center gap-1.5"
>
{viewMode === "table" ? (
<>
<LayoutGrid className="h-4 w-4 [stroke-width:1.7]" />
Kartenansicht
</>
) : (
<>
<Table className="h-4 w-4 [stroke-width:1.7]" />
Tabellenansicht
</>
)}
</Button>
<AddFamilyDialog />
</div>
</header>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
Familien
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{childCount}</span>
Kinder
</span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
Offene Einladungen
</span>
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Familien, Eltern oder Kinder suchen"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{filteredFamilies.length === 0 ? (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Users className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Keine Familien gefunden
</h3>
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Passe deine Suche an oder erstelle eine neue Familie.
</p>
{initialFamilies.length === 0 && <AddFamilyDialog />}
</div>
</Card>
) : viewMode === "table" ? (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{filteredFamilies.length}
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0 overflow-x-auto">
<UITable>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
);
return (
<TableRow key={family.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-[var(--ink)]">
{family.name}
</div>
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
{family.users.map((parent) => (
<div key={parent.id} className="min-w-0">
<div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</UITable>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
);
return (
<Card key={family.id} className="flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
<p className="text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
</p>
</div>
</div>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4 pt-0">
<div className="space-y-4">
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Eltern
</p>
<div className="space-y-2.5">
{family.users.map((parent) => (
<div key={parent.id} className="text-sm">
<div className="font-semibold text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="flex items-center gap-1.5 text-xs text-[var(--ink-muted)] mt-0.5">
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
Kinder
</p>
{family.children.length === 0 ? (
<span className="text-xs italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs text-[var(--ink-soft)] font-medium"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</div>
</div>
<div className="border-t border-[var(--hairline)] pt-3 mt-auto flex flex-wrap gap-1 justify-end">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
+4 -221
View File
@@ -1,23 +1,8 @@
import { UserRole } from "@prisma/client";
import { Mail, Search, SlidersHorizontal, Users } from "lucide-react";
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 {
Table,
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";
import { FamiliesListClient } from "./_components/families-list-client";
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
@@ -35,7 +20,6 @@ export default async function FamiliesPage() {
const kitaId = session.user.kitaId!;
// Alle Haushalte der Kita mit Elternteilen und Kindern laden.
// kitaId IMMER aus der Session — nie aus URL/Params
const families = await prisma.family.findMany({
where: { kitaId },
select: {
@@ -84,216 +68,15 @@ export default async function FamiliesPage() {
orderBy: { name: "asc" },
});
const childCount = families.reduce(
(acc, family) => acc + family.children.length,
0,
);
const canManageDuties = session.user.role === UserRole.ADMIN;
const pendingInviteCount = families.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0,
);
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
VERWALTUNG · FAMILIEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Familien{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
organisieren
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
pflegen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Ansicht
</Button>
<AddFamilyDialog />
</div>
</header>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<DirectoryMetric label="Familien" value={families.length} />
<DirectoryMetric label="Kinder" value={childCount} />
<DirectoryMetric label="Offene Einladungen" value={pendingInviteCount} />
</div>
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Familien, Eltern oder Kinder suchen" className="pl-9" />
</div>
</div>
{families.length === 0 ? (
<EmptyState />
) : (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{families.length} {families.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen
</p>
</div>
<Badge variant="secondary" className="tabular-nums">
{families.length}
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Familie</TableHead>
<TableHead>Elternteile</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{families.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt,
);
return (
<TableRow key={family.id}>
<TableCell className="pl-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
{family.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-[var(--ink)]">
{family.name}
</div>
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
{family.users.map((parent) => (
<div key={parent.id} className="min-w-0">
<div className="font-medium text-[var(--ink)]">
{parent.firstName} {parent.lastName}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
</span>
</div>
</div>
))}
</div>
</TableCell>
<TableCell>
{family.children.length === 0 ? (
<span className="text-sm italic text-[var(--ink-faint)]">
Noch keine Kinder
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{family.children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<div className="flex flex-wrap justify-end gap-1">
<EditFamilyDialog
family={{
id: family.id,
name: family.name,
parents: family.users.map((parent) => ({
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
})),
children: family.children,
}}
/>
{canManageDuties &&
family.users.map((parent) => (
<DutyManager
key={parent.id}
user={{
id: parent.id,
firstName: parent.firstName,
lastName: parent.lastName,
}}
<FamiliesListClient
initialFamilies={families}
allDuties={allDuties}
userAssignments={parent.dutyAssignments}
canManageDuties={canManageDuties}
/>
))}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
function EmptyState() {
return (
<Card className="border-dashed">
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Users className="h-6 w-6 [stroke-width:1.7]" />
</div>
<h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Noch keine Familien
</h3>
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
Lege die erste Familie an und lade das Elternteil per Link ein, sein
Passwort zu setzen.
</p>
<AddFamilyDialog />
</div>
</Card>
);
}
function DirectoryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">
{value}
</span>
{label}
</span>
);
}
@@ -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<typeof Button>["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<AdminTerminFormState>(initialFormState);
@@ -56,6 +66,25 @@ 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) {
// eslint-disable-next-line react-hooks/set-state-in-effect
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 = <TKey extends keyof AdminTerminFormState>(
key: TKey,
value: AdminTerminFormState[TKey],
@@ -76,7 +105,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 +125,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!");
toast.success(
existingTermin
? "Termin wurde erfolgreich aktualisiert!"
: "Termin wurde direkt angelegt!"
);
if (!existingTermin) {
setFormState(initialFormState);
}
setOpen(false);
}
});
@@ -110,16 +152,20 @@ export function AdminTerminModal({
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={triggerVariant} className={triggerClassName}>
<Plus className="h-4 w-4" />
{!existingTermin && <Plus className="h-4 w-4" />}
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogTitle>
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
</DialogTitle>
<DialogDescription>
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."}
</DialogDescription>
</DialogHeader>
@@ -177,7 +223,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 +235,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 +263,7 @@ export function AdminTerminModal({
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Termin erstellen"}
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
</Button>
</DialogFooter>
</form>
@@ -0,0 +1,934 @@
"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: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
{ 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">("monat");
const [searchQuery, setSearchQuery] = useState("");
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
// Month navigation state
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
// Dialog state for day clicking in Month view
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
// Delete event states
const [deleteEventId, setDeleteEventId] = useState<string | null>(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) => {
// 0. Filter by scope (own vs all)
if (filterScope === "own") {
if (item.kind === "duty" && !item.isOwn) return false;
if (item.kind === "notdienst" && !item.isOwn) return false;
}
// 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.kind === "notdienst") && item.familyName
? item.familyName.toLowerCase().includes(query)
: false;
const childMatch =
item.kind === "notdienst" && item.childName
? item.childName.toLowerCase().includes(query)
: false;
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
}
// 2. Category filter
if (activeTypes.size > 0) {
const itemType =
item.kind === "duty"
? item.isOwn
? "mein-elterndienst"
: "elterndienst"
: item.kind === "notdienst"
? item.isOwn
? "mein-notdienst"
: "notdienst"
: item.type;
if (!activeTypes.has(itemType)) return false;
}
return true;
});
}, [termine, searchQuery, activeTypes, filterScope]);
// 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 (
<div className="space-y-5">
{/* Search and Filters Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center flex-wrap gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{(["liste", "monat", "agenda"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={[
"h-9 px-4 text-[13px] font-semibold transition",
v !== "liste" ? "border-l border-[var(--hairline-soft)]" : "",
view === v
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</button>
))}
</div>
{!isAdmin && (
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)] p-0.5">
<button
type="button"
onClick={() => setFilterScope("own")}
className={[
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
filterScope === "own"
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Meine Dienste
</button>
<button
type="button"
onClick={() => setFilterScope("all")}
className={[
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
filterScope === "all"
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
].join(" ")}
>
Alle Dienste
</button>
</div>
)}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
{/* Category chips */}
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map((cat) => {
const isActive = activeTypes.has(cat.type);
return (
<button
key={cat.label}
type="button"
onClick={() => toggleType(cat.type)}
className={[
"inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition",
isActive
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
{cat.label}
</button>
);
})}
</div>
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input
placeholder="Termine suchen"
className="h-9 pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
</div>
{/* Main View Contents */}
{view === "liste" && (
<TerminList termine={filteredEvents} userId={userId} isAdmin={isAdmin} />
)}
{view === "monat" && (
<MonthView
currentMonthDate={currentMonthDate}
setCurrentMonthDate={setCurrentMonthDate}
events={filteredEvents}
isEventOnDay={isEventOnDay}
onDayClick={(day) => setSelectedDay(day)}
/>
)}
{view === "agenda" && (
<AgendaView
events={filteredEvents}
isAdmin={isAdmin}
userId={userId}
onDeleteTermin={(id, title) => {
setDeleteEventId(id);
setDeleteEventTitle(title);
}}
/>
)}
{/* Day details Dialog for Month View */}
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
</DialogTitle>
<DialogDescription>
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
{selectedDayEvents.length === 0 ? (
<p className="text-sm italic text-[var(--ink-soft)] text-center py-8">
Keine Termine oder Dienste an diesem Tag.
</p>
) : (
selectedDayEvents.map((item) => (
<div
key={item.id}
className="relative rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
>
{item.kind === "duty" ? (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{
backgroundColor: item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)",
}}
/>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<Badge
variant="secondary"
style={{
color: item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)",
borderColor:
(item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)") + "44",
backgroundColor:
(item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)") + "11",
}}
className="border text-[10px]"
>
{item.isOwn ? "Mein Elterndienst" : "Elterndienst"}
</Badge>
</div>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Familie: {item.familyName}
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(item.startDate), "dd.MM.")} -{" "}
{format(new Date(item.endDate), "dd.MM.yyyy")}
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/admin/dienste">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
</div>
</div>
) : item.kind === "notdienst" ? (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{
backgroundColor: item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)",
}}
/>
<div className="pl-2 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<Badge
variant="secondary"
style={{
color: item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)",
borderColor:
(item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)") + "44",
backgroundColor:
(item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)") + "11",
}}
className="border text-[10px]"
>
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</Badge>
</div>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Kind: {item.childName} (Familie: {item.familyName})
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(item.startDate), "dd.MM.yyyy")}
</p>
{isAdmin && (
<div className="mt-3 flex gap-2">
<a href="/dashboard/notdienst/plan">
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
Im Planer bearbeiten
</Button>
</a>
</div>
)}
</div>
</div>
) : (
<div className="flex items-start gap-3">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: getCategoryColor(item.type) }}
/>
<div className="pl-2 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border border-[var(--hairline)]"
style={{
color: getCategoryColor(item.type),
borderColor: getCategoryColor(item.type) + "44",
backgroundColor: getCategoryColor(item.type) + "11",
}}
>
{getTypeLabel(item.type)}
</span>
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`}
</p>
{item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">
{item.description}
</p>
)}
{item.participantInfo && item.showParticipantInfo && (
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
Hinweis für Teilnehmer
</div>
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
</div>
)}
{item.mitbringselListEnabled && (
<div className="mt-3">
<MitbringselList
terminId={item.id}
items={item.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
{isAdmin && (
<div className="mt-3 flex gap-2 border-t border-[var(--hairline-soft)] pt-3">
<AdminTerminModal
existingTermin={item}
triggerLabel="Bearbeiten"
triggerVariant="outline"
triggerClassName="h-7 text-xs gap-1 px-2.5"
/>
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] gap-1 px-2.5 border-[var(--hairline)]"
onClick={() => {
setDeleteEventId(item.id);
setDeleteEventTitle(item.title);
}}
>
<Trash2 className="h-3 w-3" />
Löschen
</Button>
</div>
)}
</div>
</div>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{deleteEventTitle}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteEventId(null)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// =====================================================================
// 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 (
<Card className="overflow-hidden border border-[var(--hairline)] shadow-sm">
<div className="flex items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3">
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
{format(currentMonthDate, "MMMM yyyy", { locale: de })}
</h3>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(subMonths(currentMonthDate, 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setCurrentMonthDate(new Date())}
className="h-8 text-xs font-semibold px-2.5"
>
Heute
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setCurrentMonthDate(addMonths(currentMonthDate, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 border-b border-[var(--hairline-soft)] bg-[var(--surface-3)]/20 py-2 text-center text-xs font-semibold text-[var(--ink-soft)]">
{weekdays.map((day) => (
<div key={day}>{day}</div>
))}
</div>
<div className="grid grid-cols-7 bg-[var(--hairline-soft)] gap-[1px]">
{days.map((day, idx) => {
const isCurrentMonth = isSameMonth(day, currentMonthDate);
const isToday = isSameDay(day, new Date());
const dayEvents = events.filter((item) => isEventOnDay(item, day));
return (
<div
key={day.toString() + idx}
onClick={() => 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 */}
<div className="flex items-center justify-between">
<span
className={[
"text-xs font-semibold flex h-6 w-6 items-center justify-center rounded-full",
isToday
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)] shadow-sm"
: "",
].join(" ")}
>
{format(day, "d")}
</span>
</div>
{/* Event indicators/snippets */}
<div className="mt-2 space-y-1 flex-1 flex flex-col justify-end">
{dayEvents.slice(0, 3).map((item) => {
const color =
item.kind === "duty"
? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: getCategoryColor(item.type);
return (
<div
key={item.id}
className="group relative flex items-center gap-1 rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] font-medium border border-[var(--hairline-soft)]"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate text-[var(--ink-soft)] max-w-full">
{item.title}
</span>
</div>
);
})}
{dayEvents.length > 3 && (
<div className="text-[9px] font-semibold text-[var(--accent-deep)] pl-1.5">
+ {dayEvents.length - 3} weitere
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
);
}
// =====================================================================
// 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<string, { date: Date; items: CalendarListItemDto[] }>();
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 (
<Card>
<CardContent className="flex min-h-[220px] flex-col items-center justify-center text-center p-6">
<CalendarIcon className="h-8 w-8 text-[var(--ink-muted)] mb-2" />
<h3 className="text-[16px] font-semibold text-[var(--ink)]">
Keine Termine oder Dienste gefunden
</h3>
<p className="text-sm text-[var(--ink-soft)] mt-1 max-w-xs">
Passe deine Filter an oder suche nach einem anderen Begriff.
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
{grouped.map((group) => (
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
{/* Timeline marker */}
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
{/* Date Label on left */}
<div className="pt-0.5">
<div className="text-[14px] font-bold text-[var(--ink)] leading-none">
{format(group.date, "d. MMMM", { locale: de })}
</div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] mt-1">
{format(group.date, "EEEE", { locale: de })}
</div>
</div>
{/* Items stack on right */}
<div className="space-y-3">
{group.items.map((item) => {
const color =
item.kind === "termin"
? getCategoryColor(item.type)
: item.kind === "notdienst"
? item.isOwn
? "oklch(0.60 0.18 45)"
: "oklch(0.75 0.10 50)"
: item.kind === "duty"
? item.isOwn
? "oklch(0.60 0.16 160)"
: "oklch(0.75 0.08 160)"
: "oklch(0.60 0.15 160)";
return (
<div
key={item.id}
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex items-start gap-3 min-w-0">
<div
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
style={{ backgroundColor: color }}
/>
<div className="pl-1 min-w-0">
<div className="flex items-center flex-wrap gap-2">
<h4 className="font-bold text-[15px] text-[var(--ink)] truncate">
{item.title}
</h4>
{item.kind === "termin" ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border"
style={{
color,
borderColor: color + "44",
backgroundColor: color + "11",
}}
>
{getTypeLabel(item.type)}
</span>
) : item.kind === "notdienst" ? (
<Badge
variant="secondary"
className="text-[10px]"
style={{
color,
borderColor: color + "44",
backgroundColor: color + "11",
}}
>
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
</Badge>
) : (
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
Elterndienst
</Badge>
)}
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
)}
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{item.kind === "termin"
? item.allDay
? "Ganztägig"
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
new Date(item.endDate),
"HH:mm",
)}`
: item.kind === "notdienst"
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
new Date(item.endDate),
"dd.MM.yyyy",
)}`}
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
{item.kind === "termin"
? "Kita"
: item.kind === "notdienst"
? `Kind: ${item.childName} · Familie: ${item.familyName}`
: `Familie: ${item.familyName}`}
</p>
{item.kind === "termin" && item.description && (
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
{item.description}
</p>
)}
</div>
</div>
{/* Actions for admins */}
{isAdmin && item.kind === "termin" && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<AdminTerminModal
existingTermin={item}
triggerLabel=""
triggerVariant="ghost"
triggerClassName="h-8 w-8 p-0"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => onDeleteTermin(item.id, item.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// =====================================================================
// 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";
}
}
@@ -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,10 @@ import {
MapPin,
MoreHorizontal,
WashingMachine,
Trash2,
ShieldAlert,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -22,9 +26,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";
@@ -56,12 +63,25 @@ export type DutyCalendarItemDto = {
status: DutyAssignmentStatus;
startDate: Date;
endDate: Date;
isOwn: boolean;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
export type NotdienstCalendarItemDto = {
kind: "notdienst";
id: string;
title: string;
familyName: string;
childName: string;
isOwn: boolean;
startDate: Date;
endDate: Date;
};
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
@@ -115,9 +135,11 @@ export function TerminList({
<div className="divide-y divide-[var(--hairline-soft)]">
{group.items.map((item) =>
item.kind === "duty" ? (
<DutyRow key={`duty-${item.id}`} duty={item} />
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
) : item.kind === "notdienst" ? (
<NotdienstRow key={`notdienst-${item.id}`} notdienst={item} isAdmin={isAdmin} />
) : (
<TerminRow key={`termin-${item.id}`} termin={item} />
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
),
)}
</div>
@@ -128,8 +150,9 @@ export function TerminList({
);
}
function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
const color = "var(--good)";
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
@@ -147,20 +170,73 @@ function DutyRow({ duty }: { duty: DutyCalendarItemDto }) {
{duty.familyName}
</p>
</div>
<div className="flex items-center gap-2">
<CategoryPill color={color}>Elterndienst</CategoryPill>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>
{duty.isOwn ? "Mein Elterndienst" : "Elterndienst"}
</CategoryPill>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/admin/dienste"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Dienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}
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 (
<>
<Dialog>
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
@@ -181,20 +257,58 @@ function TerminRow({ termin }: { termin: TerminListItemDto }) {
Kita
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
Details
</Button>
</DialogTrigger>
<Button variant="ghost" size="icon" aria-label="Mehr Optionen">
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<AdminTerminModal
existingTermin={termin}
triggerLabel="Bearbeiten"
triggerVariant="ghost"
triggerClassName="flex w-full items-center justify-start rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)] font-normal h-auto bg-transparent border-0"
/>
<button
type="button"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-soft)] text-left font-normal"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDeleteOpen(true);
}}
>
Löschen
</button>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
<DialogContent>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{termin.title}</DialogTitle>
<DialogDescription>
@@ -209,7 +323,9 @@ function TerminRow({ termin }: { termin: TerminListItemDto }) {
</DialogHeader>
<div className="grid gap-4">
<div className="flex">
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
</div>
{termin.description ? (
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
@@ -230,9 +346,50 @@ function TerminRow({ termin }: { termin: TerminListItemDto }) {
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
</div>
)}
{termin.mitbringselListEnabled && (
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
<DialogDescription>
Möchtest du den Termin &ldquo;{termin.title}&rdquo; wirklich löschen? Alle
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -334,3 +491,64 @@ function getTypeLabel(type: TerminType): string {
return "Sonstiges";
}
}
function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItemDto; isAdmin: boolean }) {
const color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
<DateTile date={notdienst.startDate} />
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
{notdienst.title}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
Kind: {notdienst.childName}
</span>
</p>
</div>
<div className="flex items-center gap-2 relative">
<CategoryPill color={color}>
{notdienst.isOwn ? "Mein Notdienst" : "Notdienst"}
</CategoryPill>
{isAdmin ? (
<div className="relative">
<Button
variant="ghost"
size="icon"
aria-label="Mehr Optionen"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
>
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
</Button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
<a
href="/dashboard/notdienst/plan"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
onClick={() => setMenuOpen(false)}
>
Notdienst im Planer bearbeiten
</a>
</div>
</>
)}
</div>
) : (
<div className="w-9 h-9" />
)}
</div>
</div>
);
}
+25
View File
@@ -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." };
}
}
+57 -97
View File
@@ -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" };
@@ -134,6 +134,7 @@ export default async function KalenderPage({
startDate: true,
endDate: true,
status: true,
familyId: true,
dutyType: {
select: {
name: true,
@@ -148,6 +149,32 @@ export default async function KalenderPage({
orderBy: { startDate: "asc" },
});
const notdienstAssignmentRows = await prisma.notdienstAssignment.findMany({
where: {
kitaId: session.user.kitaId,
plan: {
status: "PUBLISHED",
},
},
select: {
id: true,
date: true,
child: {
select: {
firstName: true,
lastName: true,
familyId: true,
family: {
select: {
name: true,
},
},
},
},
},
orderBy: { date: "asc" },
});
const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({
...termin,
@@ -169,15 +196,36 @@ export default async function KalenderPage({
participation.status === EventParticipationStatus.ATTENDING,
),
})),
...dutyAssignmentRows.map((assignment) => ({
...dutyAssignmentRows.map((assignment) => {
const isOwn = assignment.familyId === familyIdForParticipation;
return {
kind: "duty" as const,
id: assignment.id,
title: assignment.dutyType.name,
title: isOwn
? `Mein Elterndienst (${assignment.dutyType.name})`
: `${assignment.dutyType.name} (${assignment.family.name})`,
familyName: assignment.family.name,
status: assignment.status,
startDate: assignment.startDate,
endDate: assignment.endDate,
})),
isOwn,
};
}),
...notdienstAssignmentRows.map((assignment) => {
const isOwn = assignment.child.familyId === familyIdForParticipation;
return {
kind: "notdienst" as const,
id: assignment.id,
title: isOwn
? `Mein Notdienst (${assignment.child.firstName})`
: `Notdienst (${assignment.child.family.name})`,
familyName: assignment.child.family.name,
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
isOwn,
startDate: assignment.date,
endDate: assignment.date,
};
}),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
let allPendingTermine: PendingTerminDto[] = [];
@@ -253,7 +301,7 @@ export default async function KalenderPage({
<div>
{currentTab === "übersicht" ? (
<CalendarOverview
<CalendarOverviewClient
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
@@ -261,7 +309,7 @@ export default async function KalenderPage({
) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverview
<CalendarOverviewClient
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
@@ -270,7 +318,7 @@ export default async function KalenderPage({
</div>
</div>
) : (
<CalendarOverview
<CalendarOverviewClient
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
@@ -280,49 +328,7 @@ export default async function KalenderPage({
);
}
function CalendarOverview({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{["Liste", "Monat", "Agenda"].map((label, index) => (
<button
key={label}
type="button"
className={[
"h-9 px-4 text-[13px] font-semibold transition",
index > 0 ? "border-l border-[var(--hairline-soft)]" : "",
index === 0
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{label}
</button>
))}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
<CategoryChips />
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Termine suchen" className="h-9 pl-9" />
</div>
</div>
</div>
<TerminList termine={termine} userId={userId} isAdmin={isAdmin} />
</div>
);
}
function TabBar({
currentTab,
@@ -400,50 +406,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 (
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map(([label, type]) => (
<span
key={label}
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: getCategoryColor(type) }}
/>
{label}
</span>
))}
</div>
);
}
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)";
}
}
+14 -71
View File
@@ -1,11 +1,9 @@
import type { Metadata } from "next";
import { LogOut } from "lucide-react";
import { signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
export const metadata: Metadata = {
robots: {
@@ -15,11 +13,11 @@ export const metadata: Metadata = {
};
// =====================================================================
// Dashboard-Layout · Linke Sidebar
// Dashboard-Layout · Responsive App Shell
// ---------------------------------------------------------------------
// Server Component: liest kitaId aus der Session, holt den Kita-Namen
// aus der DB und rendert das App-Shell (Sidebar + main content area).
// Der eigentliche Seiteninhalt kommt via {children} rein.
// aus der DB und rendert die responsive DashboardShell (mit Desktop-Sidebar
// und Mobile-Drawer).
// =====================================================================
export default async function DashboardLayout({
@@ -35,75 +33,20 @@ export default async function DashboardLayout({
});
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
return (
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Sidebar ───────────────────────────────────────────────────── */}
<aside className="sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:relative max-[760px]:h-auto">
{/* Logo / Kita-Name */}
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kita.name.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kita.name}
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={session.user.role} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{getInitials(displayName)}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{session.user.role}
</p>
</div>
</div>
<form
action={async () => {
async function handleSignOut() {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
}
{/* ── Main Content ──────────────────────────────────────────────── */}
<main className="min-w-0 overflow-y-auto">
return (
<DashboardShell
kitaName={kita.name}
userName={displayName}
userRole={session.user.role}
signOutAction={handleSignOut}
>
{children}
</main>
</div>
</DashboardShell>
);
}
function getInitials(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
}
@@ -0,0 +1,132 @@
"use client";
import { useState, useTransition, type FormEvent } from "react";
import { SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateKitaNotdienstSettingsAction } from "../actions";
interface SettingsModalProps {
initialMinPerChild: number;
initialReminderDays: number;
}
export function SettingsModal({
initialMinPerChild,
initialReminderDays,
}: SettingsModalProps) {
const [open, setOpen] = useState(false);
const [minPerChild, setMinPerChild] = useState(initialMinPerChild);
const [reminderDays, setReminderDays] = useState(initialReminderDays);
const [isPending, startTransition] = useTransition();
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (minPerChild < 0 || reminderDays < 0) {
toast.error("Werte dürfen nicht negativ sein.");
return;
}
startTransition(async () => {
const res = await updateKitaNotdienstSettingsAction(
minPerChild,
reminderDays
);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Regeln erfolgreich gespeichert!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Notdienst-Einstellungen</DialogTitle>
<DialogDescription>
Passe die standardmäßigen Regeln für die Notdienst-Verteilung in deiner Kita an.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="minPerChild">
Mindesteinteilungen pro Kind/Monat
</Label>
<Input
id="minPerChild"
name="minPerChild"
type="number"
min="0"
required
value={minPerChild}
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reminderDays">
Erinnerungsfrist (Tage vor Monatsende)
</Label>
<Input
id="reminderDays"
name="reminderDays"
type="number"
min="0"
required
value={reminderDays}
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setMinPerChild(initialMinPerChild);
setReminderDays(initialReminderDays);
setOpen(false);
}}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Regeln speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+280 -10
View File
@@ -1,21 +1,36 @@
"use server";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
// =====================================================================
export async function generatePlanAction() {
export async function generatePlanAction(year?: number, month?: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth } = getTargetMonthData();
let targetYear: number;
let targetMonth: number;
if (year !== undefined && month !== undefined) {
targetYear = year;
targetMonth = month;
} else {
const data = getTargetMonthData();
targetYear = data.targetYear;
targetMonth = data.targetMonth;
}
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
@@ -33,8 +48,8 @@ export async function generatePlanAction() {
where: {
kitaId,
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
},
},
include: { child: { select: { familyId: true } } },
@@ -141,17 +156,44 @@ export async function updateAssignmentAction(
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const parsedDate = new Date(date);
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try {
await prisma.$transaction(async (tx) => {
// Prüfen, ob Plan noch DRAFT ist
const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
let txShouldSendEmails = false;
let txAssignedChildInfo: {
parentEmails: string[];
parentNames: string[];
dateLabel: string;
} | null = null;
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan || plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Plan kann nicht mehr bearbeitet werden.");
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new Error("Plan kann nicht bearbeitet werden.");
}
// Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden.
// Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte
// bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde.
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
});
if (isPublished && existingAssignment) {
if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) {
throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert werden.");
}
}
// Altes Assignment für diesen Tag löschen
@@ -167,7 +209,16 @@ export async function updateAssignmentAction(
kitaId,
active: true,
},
select: { id: true },
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
});
if (!child) {
@@ -182,9 +233,57 @@ export async function updateAssignmentAction(
date: parsedDate,
},
});
// Wenn der Plan bereits veröffentlicht ist, bereiten wir E-Mail-Benachrichtigungen vor
if (isPublished) {
const parents = child.family.users.filter((u) => !!u.email);
if (parents.length > 0) {
const localFormatDate = new Date(yr, mo - 1, dy);
txAssignedChildInfo = {
parentEmails: parents.map((p) => p.email!),
parentNames: parents.map((p) => `${p.firstName} ${p.lastName}`),
dateLabel: format(localFormatDate, "EEEE, dd. MMMM yyyy", { locale: de }),
};
txShouldSendEmails = true;
}
}
}
return {
shouldSendEmails: txShouldSendEmails,
assignedChildInfo: txAssignedChildInfo,
};
});
// E-Mails außerhalb der DB-Transaktion senden
if (shouldSendEmails && assignedChildInfo) {
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (kita) {
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) {
const email = assignedChildInfo.parentEmails[i];
const name = assignedChildInfo.parentNames[i];
await sendAppEmail({
to: email,
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
react: createElement(NotdienstAssignmentEmail, {
parentName: name,
kitaName: kita.name,
dateLabel: assignedChildInfo.dateLabel,
link,
}),
});
}
}
}
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
@@ -193,3 +292,174 @@ export async function updateAssignmentAction(
};
}
}
export async function updateKitaNotdienstSettingsAction(
minPerChild: number,
reminderDays: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.kita.update({
where: { id: kitaId },
data: {
notdienstMinPerChildPerMonth: minPerChild,
notdienstReminderDaysBefore: reminderDays,
},
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Speichern der Einstellungen." };
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
try {
// 1. Get Kita Name
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita nicht gefunden." };
}
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
const families = await prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
include: {
users: {
where: { role: UserRole.ELTERN },
},
},
});
// 3. For target month, find availability entries
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 1);
const respondedFamilies = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
child: {
select: {
familyId: true,
},
},
},
});
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
if (openFamilies.length === 0) {
return { success: true, count: 0 };
}
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
for (const family of openFamilies) {
for (const parent of family.users) {
if (!parent.email) continue;
const result = await sendAppEmail({
to: parent.email,
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
react: createElement(NotdienstAvailabilityReminderEmail, {
parentName: `${parent.firstName} ${parent.lastName}`,
kitaName: kita.name,
monthLabel,
link,
}),
});
if (result.success) {
sentCount++;
} else {
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
}
}
}
return { success: true, count: sentCount };
} catch (error) {
console.error("Fehler beim Versenden der Erinnerungen:", error);
return { error: "Fehler beim Versenden der Erinnerungen." };
}
}
export async function resetPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
// Ensure the plan belongs to this kita and is draft
const plan = await tx.notdienstPlan.findFirst({
where: { id: planId, kitaId },
});
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
}
// Delete all assignments
await tx.notdienstAssignment.deleteMany({
where: { planId },
});
// Delete the plan itself
await tx.notdienstPlan.delete({
where: { id: planId },
});
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
}
}
+54 -16
View File
@@ -1,25 +1,39 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType, UserRole } from "@prisma/client";
import { ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
export default async function PlanungsZentralePage() {
export default async function PlanungsZentralePage({
searchParams,
}: {
searchParams: Promise<{ year?: string; month?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth, monthName } = getTargetMonthData();
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
const monthDate = new Date(targetYear, targetMonth - 1, 1);
const monthName = format(monthDate, "MMMM", { locale: de });
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount] = await Promise.all([
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
@@ -27,11 +41,13 @@ export default async function PlanungsZentralePage() {
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
},
},
},
@@ -79,6 +95,13 @@ export default async function PlanungsZentralePage() {
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
@@ -90,6 +113,7 @@ export default async function PlanungsZentralePage() {
id: true,
firstName: true,
lastName: true,
familyId: true,
family: {
select: {
name: true,
@@ -114,6 +138,7 @@ export default async function PlanungsZentralePage() {
id: c.id,
name: `${c.firstName} ${c.lastName}`,
parentName,
familyId: c.familyId,
};
});
@@ -156,10 +181,15 @@ export default async function PlanungsZentralePage() {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
);
const isOriginallyUnassigned =
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
return {
date: day.toISOString(),
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
};
});
@@ -181,22 +211,28 @@ export default async function PlanungsZentralePage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="print:hidden flex shrink-0 items-center gap-2">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-r border-[var(--hairline-soft)]">
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-r border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<div className="flex h-9 min-w-32 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
</Link>
<div className="flex h-9 min-w-36 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
{monthName} {targetYear}
</div>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-none border-l border-[var(--hairline-soft)]">
<Link
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, 1))}&month=${getMonth(addMonths(monthDate, 1)) + 1}`}
className="inline-flex h-9 w-9 items-center justify-center border-l border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
>
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</Link>
</div>
<Button variant="ghost" size="sm">
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
Einstellungen
</Button>
<SettingsModal
initialMinPerChild={kita.notdienstMinPerChildPerMonth}
initialReminderDays={kita.notdienstReminderDaysBefore}
/>
</div>
</header>
@@ -214,6 +250,8 @@ export default async function PlanungsZentralePage() {
coverageFactor={coverageFactor}
closureCount={closureCount}
monthName={monthName}
year={targetYear}
month={targetMonth}
/>
</div>
);
+136 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
@@ -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 {
@@ -31,11 +41,13 @@ interface PlanViewProps {
date: string;
assignmentId: string | null;
childId: string | null;
isOriginallyUnassigned?: boolean;
}[];
allChildren: {
id: string;
name: string;
parentName: string;
familyId: string;
}[];
familyStatuses: {
id: string;
@@ -54,6 +66,8 @@ interface PlanViewProps {
coverageFactor: number;
closureCount: number;
monthName: string;
year: number;
month: number;
}
export function PlanView({
@@ -70,16 +84,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,8 +117,32 @@ 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;
if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return;
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
@@ -132,8 +173,12 @@ export function PlanView({
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost">
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerungen verschicken
</Button>
</div>
@@ -199,8 +244,16 @@ export function PlanView({
</div>
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
<Button variant="ghost" disabled={openFamilies === 0}>
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending || openFamilies === 0}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
<Send className="h-4 w-4 [stroke-width:1.7]" />
)}
Erinnerung an offene senden ({openFamilies})
</Button>
</div>
@@ -282,7 +335,7 @@ export function PlanView({
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="print:hidden flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
<TabButton
active={activeTab === "preview"}
@@ -309,13 +362,15 @@ export function PlanView({
Plan veröffentlichen
</Button>
)}
<Button variant="ghost">
<Button variant="ghost" onClick={() => window.print()}>
<Download className="h-4 w-4 [stroke-width:1.7]" />
Als PDF exportieren
</Button>
<Button
variant="ghost"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setResetDialogOpen(true)}
disabled={isPending}
>
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
Plan zurücksetzen
@@ -323,7 +378,7 @@ export function PlanView({
</div>
</div>
<Card className="flex items-center justify-between p-4">
<Card className="print:hidden flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
@@ -347,6 +402,35 @@ export function PlanView({
onAssignmentChange={handleAssignmentChange}
/>
)}
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Plan zurücksetzen?</DialogTitle>
<DialogDescription>
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setResetDialogOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isPending}
>
{isPending ? "Zurücksetzen..." : "Ja, Plan zurücksetzen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -435,7 +519,8 @@ function PlanPreviewTable({
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const dateObj = new Date(day.date);
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,
);
@@ -484,6 +569,31 @@ function ManualAssignmentTable({
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
// Map childId to familyId for quick lookup
const childFamilyMap = useMemo(() => {
const map = new Map<string, string>();
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<string, number>();
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 (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
@@ -492,7 +602,8 @@ function ManualAssignmentTable({
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{days.map((day) => {
const dateObj = new Date(day.date);
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,
);
@@ -506,7 +617,8 @@ function ManualAssignmentTable({
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
{isDraft || day.isOriginallyUnassigned ? (
<div className="flex flex-wrap items-center gap-2">
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
@@ -516,12 +628,22 @@ function ManualAssignmentTable({
}
>
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => (
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name})
{child.parentName} ({child.name}) {count}x
</option>
))}
);
})}
</select>
{!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
Nachträglich änderbar
</span>
)}
</div>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}
@@ -16,6 +16,7 @@ type ContactFormProps = {
street: string | null;
postalCode: string | null;
city: string | null;
directoryOptIn: boolean;
};
};
@@ -30,6 +31,7 @@ export function ContactForm({ contact }: ContactFormProps) {
street: String(formData.get("street") ?? ""),
postalCode: String(formData.get("postalCode") ?? ""),
city: String(formData.get("city") ?? ""),
directoryOptIn: formData.get("directoryOptIn") === "on",
});
if (result.error) {
@@ -106,6 +108,22 @@ export function ContactForm({ contact }: ContactFormProps) {
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
id="directoryOptIn"
name="directoryOptIn"
defaultChecked={contact.directoryOptIn}
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
/>
<Label
htmlFor="directoryOptIn"
className="font-normal cursor-pointer text-xs text-[var(--ink-soft)]"
>
Im Adressbuch anzeigen (Datenschutzerklärung)
</Label>
</div>
<div className="flex justify-end pt-1">
<Button type="submit" disabled={isPending}>
{isPending ? (
@@ -5,6 +5,7 @@ import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Dialog,
DialogContent,
@@ -51,6 +52,7 @@ export function MyChildrenManager({
const [editingChild, setEditingChild] = useState<MyChild | null>(null);
const [editPayload, setEditPayload] =
useState<ChildPayload>(emptyChildPayload);
const [childToDelete, setChildToDelete] = useState<MyChild | null>(null);
const [isPending, startTransition] = useTransition();
function openEditor(child: MyChild) {
@@ -91,11 +93,6 @@ export function MyChildrenManager({
}
function handleDelete(child: MyChild) {
const confirmed = confirm(
`${child.firstName} ${child.lastName} wirklich entfernen?`,
);
if (!confirmed) return;
startTransition(async () => {
const result = await deleteMyChild(child.id);
if (result.error) {
@@ -104,6 +101,7 @@ export function MyChildrenManager({
}
toast.success("Kind entfernt.");
setChildToDelete(null);
});
}
@@ -157,7 +155,7 @@ export function MyChildrenManager({
variant="ghost"
size="icon"
className="h-7 w-7 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => handleDelete(child)}
onClick={() => setChildToDelete(child)}
disabled={isPending || !canManage}
aria-label="Kind löschen"
>
@@ -250,6 +248,25 @@ export function MyChildrenManager({
</form>
</DialogContent>
</Dialog>
<ConfirmDialog
open={!!childToDelete}
onOpenChange={(open) => {
if (!open) setChildToDelete(null);
}}
title="Kind entfernen?"
description={
childToDelete
? `${childToDelete.firstName} ${childToDelete.lastName} wird aus deinem Haushalt entfernt.`
: "Dieses Kind wird aus deinem Haushalt entfernt."
}
confirmLabel="Entfernen"
pendingLabel="Wird entfernt..."
isPending={isPending}
onConfirm={() => {
if (childToDelete) handleDelete(childToDelete);
}}
/>
</div>
);
}
+16
View File
@@ -80,6 +80,7 @@ const contactSchema = z.object({
"Bitte eine gültige Postleitzahl angeben.",
),
city: z.string().trim().max(100).optional(),
directoryOptIn: z.boolean().optional(),
});
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
@@ -183,6 +184,20 @@ export async function updateMyContact(rawPayload: unknown) {
}
try {
const currentUser = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { directoryOptInAt: true },
});
let newDirectoryOptInAt: Date | null = currentUser.directoryOptInAt;
if (parsed.data.directoryOptIn) {
if (!newDirectoryOptInAt) {
newDirectoryOptInAt = new Date();
}
} else {
newDirectoryOptInAt = null;
}
await prisma.user.update({
where: { id: session.user.id },
data: {
@@ -190,6 +205,7 @@ export async function updateMyContact(rawPayload: unknown) {
street: parsed.data.street || null,
postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null,
directoryOptInAt: newDirectoryOptInAt,
},
});
+2
View File
@@ -51,6 +51,7 @@ export default async function ProfilPage() {
street: true,
postalCode: true,
city: true,
directoryOptInAt: true,
createdAt: true,
family: {
select: {
@@ -173,6 +174,7 @@ export default async function ProfilPage() {
street: user.street,
postalCode: user.postalCode,
city: user.city,
directoryOptIn: !!user.directoryOptInAt,
}}
/>
</CardContent>
+58 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useActionState } from "react";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,8 +10,24 @@ import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = {};
export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
export function LoginForm({
callbackUrl,
devAdminEmail,
devAdminPassword,
devParentEmail,
devParentPassword,
}: {
callbackUrl: string;
devAdminEmail?: string;
devAdminPassword?: string;
devParentEmail?: string;
devParentPassword?: string;
}) {
const [state, formAction, pending] = useActionState(loginAction, initialState);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const showDevHelpers = !!(devAdminEmail || devParentEmail);
return (
<form action={formAction} className="space-y-4">
@@ -26,6 +42,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
autoComplete="email"
required
aria-invalid={!!state.errors?.email}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{state.errors?.email?.[0] && (
<p className="text-xs text-destructive">{state.errors.email[0]}</p>
@@ -41,6 +59,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
autoComplete="current-password"
required
aria-invalid={!!state.errors?.password}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{state.errors?.password?.[0] && (
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
@@ -56,6 +76,42 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Anmelden…" : "Anmelden"}
</Button>
{showDevHelpers && (
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
<div className="grid grid-cols-2 gap-2">
{devAdminEmail && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setEmail(devAdminEmail);
setPassword(devAdminPassword || "");
}}
className="text-xs py-1.5 h-auto cursor-pointer"
>
🔑 Admin
</Button>
)}
{devParentEmail && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setEmail(devParentEmail);
setPassword(devParentPassword || "");
}}
className="text-xs py-1.5 h-auto cursor-pointer"
>
👪 Eltern
</Button>
)}
</div>
</div>
)}
</form>
);
}
+13 -1
View File
@@ -36,6 +36,12 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const resolvedSearchParams = await searchParams;
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
const isDev = process.env.NODE_ENV === "development";
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md">
@@ -44,7 +50,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
<CardDescription>Willkommen zurück.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<LoginForm callbackUrl={callbackUrl} />
<LoginForm
callbackUrl={callbackUrl}
devAdminEmail={devAdminEmail}
devAdminPassword={devAdminPassword}
devParentEmail={devParentEmail}
devParentPassword={devParentPassword}
/>
<p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline">
@@ -0,0 +1,162 @@
"use client";
import React, { useState } from "react";
import { LogOut, Menu, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SidebarNav } from "./sidebar-nav";
import { UserRole } from "@prisma/client";
type DashboardShellProps = {
kitaName: string;
userName: string;
userRole: UserRole;
children: React.ReactNode;
signOutAction: () => void | Promise<void>;
};
export function DashboardShell({
kitaName,
userName,
userRole,
children,
signOutAction,
}: DashboardShellProps) {
const [isOpen, setIsOpen] = useState(false);
const displayName = userName;
const initials = getInitials(displayName);
return (
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
{/* ── Desktop Sidebar ── */}
<aside className="print:hidden sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:hidden">
{/* Logo / Kita-Name */}
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kitaName.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kitaName}
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-6">
<SidebarNav role={userRole} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{initials}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{userRole}
</p>
</div>
</div>
<form action={signOutAction}>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
{/* ── Mobile Top Header Bar ── */}
<header className="print:hidden hidden max-[760px]:flex sticky top-0 z-40 h-16 items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface)] px-5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
{kitaName.slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
{kitaName}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Menü schließen" : "Menü öffnen"}
>
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</Button>
</header>
{/* ── Mobile Sidebar Drawer/Overlay ── */}
{isOpen && (
<div className="print:hidden fixed inset-0 z-30 hidden max-[760px]:block">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-xs transition-opacity"
onClick={() => setIsOpen(false)}
/>
{/* Drawer content */}
<aside className="fixed bottom-0 top-16 left-0 flex w-[280px] max-w-[85vw] flex-col border-r border-[var(--hairline)] bg-[var(--surface)] shadow-2xl transition-transform">
<div className="flex-1 overflow-y-auto py-6" onClick={() => setIsOpen(false)}>
<SidebarNav role={userRole} />
</div>
{/* Footer */}
<div className="border-t border-[var(--hairline-soft)] p-4">
<div className="mb-3 flex items-center gap-2.5 px-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
{initials}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--ink)]">
{displayName}
</p>
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
{userRole}
</p>
</div>
</div>
<form action={signOutAction}>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
</div>
)}
{/* ── Main Content Area ── */}
<main className="min-w-0 overflow-y-auto max-[760px]:pt-0">
{children}
</main>
</div>
);
}
function getInitials(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { type ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
confirmLabel: string;
cancelLabel?: string;
pendingLabel?: string;
isPending?: boolean;
children?: ReactNode;
onConfirm: () => void | Promise<void>;
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel = "Abbrechen",
pendingLabel = "Wird ausgeführt...",
isPending = false,
children,
onConfirm,
}: ConfirmDialogProps) {
async function handleConfirm() {
await onConfirm();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="border-[var(--danger)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-[var(--danger)]">
<AlertTriangle className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription className="pt-1 text-sm leading-6">
{description}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
Diese Aktion kann nicht rückgängig gemacht werden.
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
{cancelLabel}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleConfirm}
disabled={isPending}
>
{isPending ? pendingLabel : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+189
View File
@@ -0,0 +1,189 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import { baseBodyStyle, colors, fontStack } from "./_styles";
type NotdienstAssignmentEmailProps = {
parentName: string;
kitaName: string;
dateLabel: string;
link: string;
};
export function NotdienstAssignmentEmail({
parentName,
kitaName,
dateLabel,
link,
}: NotdienstAssignmentEmailProps) {
return (
<Html lang="de">
<Head />
<Preview>Aktualisierung Notdienst-Plan: Zuteilung für {dateLabel}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.card}>
<Section style={styles.brandBar}>
<Text style={styles.brand}>Kita-Planer</Text>
</Section>
<Text style={styles.kicker}>Notdienst-Einteilung</Text>
<Heading style={styles.heading}>Neuer Notdienst-Termin zugewiesen</Heading>
<Text style={styles.lead}>
Hallo {parentName},
</Text>
<Text style={styles.text}>
in der Kita <strong>{kitaName}</strong> gab es eine Änderung an der Notdienst-Planung.
Du wurdest für folgenden Tag für den Notdienst eingeteilt:
</Text>
<Section style={styles.dateBadge}>
<Text style={styles.dateText}>{dateLabel}</Text>
</Section>
<Text style={styles.text}>
Bitte merke dir diesen Termin vor. Du kannst den vollständigen Plan auch jederzeit online im Kita-Planer einsehen.
</Text>
<Button href={link} style={styles.button}>
Zum Kita-Planer
</Button>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</Text>
<Text style={styles.linkText}>{link}</Text>
</Section>
<Text style={styles.footer}>
Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
</Text>
</Container>
</Body>
</Html>
);
}
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,
},
dateBadge: {
padding: "16px",
backgroundColor: "#f7f5ef",
borderLeft: `4px solid ${colors.brandGreen}`,
borderRadius: "4px",
margin: "0 0 24px",
fontFamily: fontStack,
},
dateText: {
margin: 0,
fontSize: "18px",
fontWeight: 700,
color: colors.textPrimary,
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,
},
};
@@ -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 (
<Html lang="de">
<Head />
<Preview>Erinnerung: Verfügbarkeiten für den Notdienst {monthLabel} eintragen</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.card}>
<Section style={styles.brandBar}>
<Text style={styles.brand}>Kita-Planer</Text>
</Section>
<Text style={styles.kicker}>Erinnerung Notdienst-Planung</Text>
<Heading style={styles.heading}>Verfügbarkeiten für {monthLabel} eintragen</Heading>
<Text style={styles.lead}>
Hallo {parentName},
</Text>
<Text style={styles.text}>
für den Monat <strong>{monthLabel}</strong> läuft aktuell die Planung des Notdienstes in der Kita <strong>{kitaName}</strong>.
Bitte trage deine Verfügbarkeiten im Planer ein, damit wir die Tage fair verteilen können.
</Text>
<Button href={link} style={styles.button}>
Verfügbarkeiten eintragen
</Button>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</Text>
<Text style={styles.linkText}>{link}</Text>
</Section>
<Text style={styles.footer}>
Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
</Text>
</Container>
</Body>
</Html>
);
}
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,
},
};
+19 -13
View File
@@ -1,13 +1,8 @@
import {
addMonths,
endOfMonth,
getDate,
getYear,
getMonth,
isWeekend,
eachDayOfInterval,
startOfMonth,
startOfDay,
format,
} from "date-fns";
import { de } from "date-fns/locale";
@@ -36,17 +31,28 @@ export function getTargetMonthData(now = new Date()) {
}
export function getWorkingDaysOfMonth(year: number, month: number) {
// month ist 1-12, Date erwartet 0-11
const start = startOfMonth(new Date(year, month - 1));
const end = endOfMonth(start);
const start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 0)); // last day of month
const days = eachDayOfInterval({ start, end });
return days
.filter((day) => !isWeekend(day))
.map((day) => startOfDay(day));
const days: Date[] = [];
const current = new Date(start);
while (current <= end) {
const dayOfWeek = current.getUTCDay(); // 0 is Sunday, 6 is Saturday
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
days.push(new Date(current));
}
current.setUTCDate(current.getUTCDate() + 1);
}
return days;
}
export function formatDateKey(date: Date) {
return format(date, "yyyy-MM-dd");
}
export function formatUtcDateKey(date: Date) {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
+13
View File
@@ -16,6 +16,9 @@ type SendAppEmailResult =
| { success: false; error: string };
export function getAppEmailConfigError() {
if (process.env.NODE_ENV === "development") {
return null;
}
if (!process.env.RESEND_API_KEY) {
return "RESEND_API_KEY ist nicht konfiguriert.";
}
@@ -35,6 +38,16 @@ export async function sendAppEmail({
replyTo,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (!process.env.RESEND_API_KEY) {
if (process.env.NODE_ENV === "development") {
console.log("\n✉️ [DEV EMAIL SIMULATION]");
console.log(`From: ${from || "not-configured"}`);
console.log(`To: ${Array.isArray(to) ? to.join(", ") : to}`);
console.log(`Subject: ${subject}`);
console.log("-----------------------------------------");
console.log("Email body react element is rendered.");
console.log("=========================================\n");
return { success: true, id: "dev-simulated-id" };
}
return {
success: false,
error: "RESEND_API_KEY ist nicht konfiguriert.",