Add non-destructive dev seed on startup

This commit is contained in:
t.indorf
2026-05-06 22:31:07 +02:00
parent 9f452fccac
commit b686e714ff
77 changed files with 10862 additions and 87 deletions
+5
View File
@@ -39,3 +39,8 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# environment
.env
.env*.local
+2463 -11
View File
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -1,26 +1,57 @@
{ {
"name": "kita-planer-tmp", "name": "kita-planer",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"db:seed": "NODE_ENV=development prisma db seed",
"db:seed:reset": "NODE_ENV=development prisma db seed -- --reset"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@prisma/client": "^6.19.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@react-email/components": "^1.0.12",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.14.0",
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"resend": "^6.12.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "16.2.4",
"prisma": "^6.19.3",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }
+127
View File
@@ -0,0 +1,127 @@
Master-Spezifikation: SaaS Kita-Planer für Elternvereine
1. Projektübersicht
Entwicklung einer mandantenfähigen (Multi-Tenant) Web-Anwendung zur Organisation von Elternvereinen/Elterninitiativen (Kitas). Das Kernziel ist die Digitalisierung des Notdienstes, der Terminplanung und der Stammdatenverwaltung. Das System wird als SaaS-Lösung (Software as a Service) aufgebaut, sodass sich unabhängige Kitas selbstständig registrieren und die Plattform nutzen können.
2. Architektur & Tech-Stack
Um externe Kosten (Vendor-Lock-in) zu vermeiden, wird ein komplett offener und flexibel hostbarer Stack verwendet:
Framework: Next.js (App Router, React)
Datenbank: PostgreSQL (Lokal oder Cloud-hosted)
ORM: Prisma ORM
Authentifizierung: NextAuth.js (Auth.js) mit E-Mail/Passwort-Credentials
Styling & UI: Tailwind CSS, shadcn/ui, Lucide Icons
E-Mail-Versand: Nodemailer oder Resend (für Notdienst-Alarme und Einladungen)
Mandantenfähigkeit (Multi-Tenancy): Umsetzung strikt auf Datenbank- und Applikationsebene. Jede Kita erhält bei Registrierung eine eindeutige kita_id (Tenant-ID). Strenge Regel: Jeder Datensatz in der Datenbank (User, Kinder, Termine, Notdienste etc.) muss diese kita_id referenzieren.
3. Rollenkonzept (RBAC)
Super-Admin: Systembetreuer (verwaltet die Infrastruktur, operiert außerhalb des normalen Kita-Alltags).
Admin (Vorstand): Kann alles innerhalb seiner Kita verwalten (Einstellungen ändern, Eltern einladen, Termine freigeben, Ämter verteilen). Der Gründer einer Kita erhält diese Rolle automatisch.
Notdienst-Koordinator: Spezielle Berechtigung für bestimmte Elternteile. Darf den Notdienst-Plan generieren, manuell anpassen und den Notdienst-Alarm auslösen.
Elternteil: Standard-Nutzer. Kann eigene Verfügbarkeiten eintragen, Termine sehen, das interne Adressbuch nutzen und Mitbringsel bei Festen eintragen.
4. Feature-Module
Modul 0: SaaS-Onboarding & Einrichtung
Landingpage: Öffentliche Startseite zur kostenlosen Registrierung einer neuen Kita.
Registrierung: Gründer gibt E-Mail/Passwort ein und wird automatisch erster Admin eines neuen Mandanten (kita_id).
Einrichtungs-Wizard:
Name der Kita festlegen.
Module aktivieren/deaktivieren (z.B. Notdienst-Modul).
Spezifische Regeln definieren (z.B. "Mindestanzahl Notdienst-Termine pro Kind im Monat").
Modul 1: Notdienst-Planung (Kern-Feature)
Dateneingabe: Eltern müssen für den Folgemonat X Termine pro Kind als Verfügbarkeit eintragen. Das System validiert die Eingabe anhand der Kita-Einstellungen.
Erinnerung (Cronjob): Automatischer E-Mail-Versand an säumige Eltern ca. eine Woche vor Ende der Eintragsfrist.
Plan-Generierung: Der Notdienst-Koordinator löst die Verteilung aus. Der Algorithmus verteilt die Tage zufällig, aber fair über alle eingegebenen Verfügbarkeiten (Start jeden Monat bei null).
Manuelle Bearbeitung: Der generierte Plan ist zunächst ein Entwurf. Der Koordinator kann Tage überschreiben oder Lücken manuell füllen (z.B. nach Absprache in Messenger-Gruppen), bevor der Plan "veröffentlicht" wird.
Alarmierung (Workflow):
Bei Krankmeldung einer Fachkraft wählt der Koordinator den aktuellen Tag und klickt auf "Notdienst auslösen".
Das eingeteilte Elternteil erhält sofort eine E-Mail mit einem Bestätigungslink.
In der App sieht der Koordinator den Status: "Wartend" (gelbes Lade-Icon).
Klickt das Elternteil den Link, wechselt der Status auf "Bestätigt" (grün).
Fallback: Erfolgt keine Bestätigung, klärt der Koordinator den Rest außerhalb der App.
Modul 2: Terminkalender & Essenslisten
Kalender-Sichtbarkeit: Alle bestätigten Termine sind für alle eingeloggten Nutzer einer Kita sichtbar. Kategorien: Kita-Fest, Schließtag, Interner Geburtstag, Externer Geburtstag, Sonstiges.
Buchungs-Workflow:
Eltern können Tage für private Nutzung (z.B. Kindergeburtstage) "anfragen". Status ist Ausstehend und der Slot wird blockiert.
Admin (Vorstand) muss die Anfrage bestätigen oder ablehnen.
Admins können Termine (z.B. Anfragen von Externen) direkt anlegen.
Flexible Mitbring-Listen: Der Admin kann zu jedem Termin eine Essens-/Mitbringliste aktivieren. Eltern können über ein freies Textfeld Einträge hinzufügen (z.B. "Familie Müller: Nudelsalat") und ihre eigenen Einträge bearbeiten/löschen.
Modul 3: Eltern-, Kinder- und Ämterverwaltung
Onboarding (Invite-Only): Admins legen neue Eltern (Vorname, Nachname, E-Mail) im System an und verknüpft sie mit den entsprechenden Kindern. Das System sendet einen Einladungs-Link zur Passwortvergabe.
Feste Elterndienste (Ämter): Jedem Eltern-Account kann durch den Admin ein fester Dienst zugewiesen werden (z.B. "Wäschedienst", "Einkauf"). Dies ist auf dem Profil für alle sichtbar.
Kita-Adressbuch (Opt-In): Nach expliziter Zustimmung sind Eltern mit ihren Kontaktdaten im internen Kita-Adressbuch für andere Eltern sichtbar (zur Erleichterung von Spielverabredungen).
Modul 4: ErzieherInnen-Verwaltung
Werden als reine Stammdaten (Namen) vom Admin in einer separaten Tabelle gepflegt.
Sie dienen ausschließlich als Referenz für die Notdienst-Logik (Auswahl: "Wer ist heute krank?").
Sie erhalten keine System-Accounts/Logins.
Modul 5: Sicherheit, DSGVO & Privacy by Design
Dieses System verarbeitet sensible, personenbezogene Daten von Kindern. Das Architektur- und Datenbankdesign muss kompromisslos sicher sein. Folgende Regeln sind beim Schreiben des Codes strikt einzuhalten:
1. Strikte Mandanten-Isolierung (Tenant Isolation):
Jede Datenbankabfrage (Find, Update, Delete) muss zwingend den Filter where: { kita_id: user.kita_id } enthalten.
Es darf niemals möglich sein, dass durch Manipulation von API-Routen oder URLs ein Nutzer einer Kita auf den Datensatz einer anderen Kita zugreift. Dies ist durch Middleware oder sichere Server Actions zu validieren.
2. DSGVO-Einwilligung (Consent Logging):
Das User-Modell in Prisma muss Felder wie privacy_policy_accepted_at (DateTime) und directory_opt_in_at (DateTime) erhalten.
Ohne gesetzten Zeitstempel bei der Datenschutzerklärung wird der Zugriff auf die App-Inhalte blockiert (Redirect zum Onboarding-Screen).
3. Datenminimierung & Löschkonzept ("Recht auf Vergessenwerden"):
Es wird striktes onDelete: Cascade im Prisma-Schema verwendet. Löscht ein Admin seine Kita, müssen alle zugehörigen User, Kinder, Termine und Notdienste rückstandslos aus der Datenbank entfernt werden.
Nutzer müssen in ihren Profil-Einstellungen einen Button "Account löschen" haben, der ihre Daten und die ihrer Kinder (sofern kein anderer verknüpfter Elternteil existiert) endgültig vernichtet. Keine "Soft-Deletes" für personenbezogene Daten.
4. Authentifizierung & Transport-Sicherheit:
Vollständige Nutzung von NextAuth.js für sicheres Session-Management (HTTP-only, Secure Cookies, CSRF-Schutz).
Sicheres Hashing von Passwörtern.
5. API-Schutz (Rate Limiting):
Kritische Endpunkte (Login, Registrierung, Passwort-Reset) müssen vor Brute-Force-Attacken geschützt werden.
6. Entwicklungs-Fokus für die KI
Bitte beginne mit der Initialisierung des Next.js Projekts und der Erstellung des schema.prisma.
Achte von der ersten Zeile an penibel auf die Mandantenfähigkeit (kita_id) in allen relevanten Tabellen und auf die korrekten Relationen zwischen Kitas, Usern (Eltern), Kindern und Terminen.
Präsentiere mir zuerst das geplante schema.prisma zur Abnahme, bevor wir mit dem Frontend oder den API-Routen beginnen.
+512
View File
@@ -0,0 +1,512 @@
// =====================================================================
// Kita-Planer · Prisma Schema
// ---------------------------------------------------------------------
// Multi-Tenant-SaaS für Elternvereine. Tenant = `Kita`.
//
// Sicherheits-Leitplanken (siehe planung.md §5):
// • Jedes mandantengebundene Modell trägt `kitaId` und ist über
// `Kita` per `onDelete: Cascade` verknüpft → DSGVO-konformes
// "Recht auf Vergessenwerden".
// • Auf Applikations-/Server-Action-Ebene MUSS jeder Query mit
// `where: { kitaId: session.user.kitaId }` versehen werden.
// • Keine Soft-Deletes auf personenbezogene Daten.
// =====================================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =====================================================================
// ENUMS
// =====================================================================
/// Rollen-basierte Zugriffskontrolle (RBAC).
/// SUPERADMIN existiert systemweit (kitaId optional);
/// ADMIN, KOORDINATOR und ELTERN sind immer einer Kita zugeordnet.
enum UserRole {
SUPERADMIN
ADMIN
KOORDINATOR
ELTERN
}
enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
REVOKED
}
enum TerminType {
KITA_FEST
SCHLIESSTAG
TEAMTAG
MITMACH_TAG
ELTERNABEND
MITGLIEDERVERSAMMLUNG
ELTERNCAFE
GEBURTSTAG_INTERN
GEBURTSTAG_EXTERN
SONSTIGES
}
enum TerminStatus {
PENDING
CONFIRMED
REJECTED
CANCELLED
}
enum NotdienstPlanStatus {
DRAFT
PUBLISHED
}
enum NotdienstAlertStatus {
PENDING
CONFIRMED
CANCELLED
}
// =====================================================================
// TENANT
// =====================================================================
model Kita {
id String @id @default(cuid())
name String
slug String @unique
// Modul-Aktivierung (Einrichtungs-Wizard, Modul 0)
notdienstModuleEnabled Boolean @default(true)
terminModuleEnabled Boolean @default(true)
adressbuchModuleEnabled Boolean @default(true)
// Mandantenspezifische Regeln
notdienstMinPerChildPerMonth Int @default(2)
notdienstReminderDaysBefore Int @default(7)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations (Cascade auf alle Tenant-Daten)
users User[]
children Child[]
educators Educator[]
parentDuties ParentDuty[]
parentDutyAssignments ParentDutyAssignment[]
invitations Invitation[]
termine Termin[]
mitbringselItems MitbringselItem[]
notdienstPlans NotdienstPlan[]
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
notdienstAlerts NotdienstAlert[]
childParents ChildParent[]
@@map("kitas")
}
// =====================================================================
// USERS
// =====================================================================
model User {
id String @id @default(cuid())
/// Mandantenzuordnung. Nullable nur für `SUPERADMIN`.
/// Bei Löschung der Kita werden alle zugeordneten User kaskadiert.
kitaId String?
kita Kita? @relation(fields: [kitaId], references: [id], onDelete: Cascade)
email String @unique
passwordHash String
firstName String
lastName String
role UserRole @default(ELTERN)
// Adressbuch / Kontaktdaten (nur sichtbar bei directoryOptIn)
phone String?
street String?
postalCode String?
city String?
// ---- DSGVO Consent Logging (planung.md §5.2) ----
/// Zeitstempel der Annahme der Datenschutzerklärung.
/// Ohne diesen Wert blockiert die App den Zugriff (Redirect → Onboarding).
privacyPolicyAcceptedAt DateTime?
/// Versionsstring der akzeptierten Datenschutzerklärung (z.B. "2025-04-01").
privacyPolicyVersion String?
/// Zeitstempel des Opt-Ins für das interne Kita-Adressbuch (Modul 3).
directoryOptInAt DateTime?
// Auth-Bookkeeping
emailVerifiedAt DateTime?
lastLoginAt DateTime?
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
childLinks ChildParent[]
dutyAssignments ParentDutyAssignment[]
notdienstAvailabilities NotdienstAvailability[]
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
termineCreated Termin[] @relation("TerminCreator")
termineApproved Termin[] @relation("TerminApprover")
mitbringselItems MitbringselItem[]
invitationsCreated Invitation[] @relation("InvitationCreator")
@@index([kitaId])
@@index([kitaId, role])
@@map("users")
}
// =====================================================================
// CHILDREN
// =====================================================================
model Child {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
firstName String
lastName String
dateOfBirth DateTime?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parentLinks ChildParent[]
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
@@index([kitaId])
@@map("children")
}
/// Verknüpfung Kind ↔ Elternteil (m:n).
/// Kaskadiert über beide Seiten, damit das Löschen eines Users
/// oder Kindes keine Datenleichen hinterlässt.
model ChildParent {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
childId String
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([childId, userId])
@@index([kitaId])
@@index([userId])
@@map("child_parents")
}
// =====================================================================
// EDUCATORS (ErzieherInnen — reine Stammdaten, keine Logins, Modul 4)
// =====================================================================
model Educator {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
firstName String
lastName String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notdienstAlerts NotdienstAlert[]
@@index([kitaId])
@@map("educators")
}
// =====================================================================
// PARENT DUTIES (Feste Ämter / Elterndienste, Modul 3)
// =====================================================================
model ParentDuty {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
name String
description String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments ParentDutyAssignment[]
@@unique([kitaId, name])
@@index([kitaId])
@@map("parent_duties")
}
model ParentDutyAssignment {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
dutyId String
duty ParentDuty @relation(fields: [dutyId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([dutyId, userId])
@@index([kitaId])
@@index([userId])
@@map("parent_duty_assignments")
}
// =====================================================================
// INVITATIONS (Invite-Only Onboarding, Modul 3)
// =====================================================================
model Invitation {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
email String
role UserRole @default(ELTERN)
token String @unique
status InvitationStatus @default(PENDING)
expiresAt DateTime
acceptedAt DateTime?
invitedById String?
invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([kitaId, email])
@@index([kitaId])
@@map("invitations")
}
// =====================================================================
// TERMINE (Kalender, Modul 2)
// =====================================================================
model Termin {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
title String
description String?
type TerminType
status TerminStatus @default(PENDING)
startDate DateTime
endDate DateTime
allDay Boolean @default(false)
/// Mitbringliste pro Termin aktivierbar (Modul 2, "Flexible Mitbring-Listen")
mitbringselListEnabled Boolean @default(false)
createdById String?
createdBy User? @relation("TerminCreator", fields: [createdById], references: [id], onDelete: SetNull)
approvedById String?
approvedBy User? @relation("TerminApprover", fields: [approvedById], references: [id], onDelete: SetNull)
approvedAt DateTime?
rejectionReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
mitbringselItems MitbringselItem[]
@@index([kitaId, startDate])
@@index([kitaId, status])
@@map("termine")
}
model MitbringselItem {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
terminId String
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
/// Cascade: Beim Löschen eines Accounts werden dessen Mitbringsel-Einträge
/// rückstandslos mitgelöscht (DSGVO "Recht auf Vergessenwerden").
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([kitaId])
@@index([terminId])
@@map("mitbringsel_items")
}
// =====================================================================
// NOTDIENST (Modul 1 Kern-Feature)
// =====================================================================
/// Monatlicher Notdienst-Plan. Erst DRAFT (Koordinator bearbeitet),
/// nach "Veröffentlichung" PUBLISHED.
model NotdienstPlan {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
year Int
/// Monat 112.
month Int
status NotdienstPlanStatus @default(DRAFT)
createdById String?
createdBy User? @relation("NotdienstPlanCreator", fields: [createdById], references: [id], onDelete: SetNull)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments NotdienstAssignment[]
@@unique([kitaId, year, month])
@@index([kitaId])
@@map("notdienst_plans")
}
/// Verfügbarkeits-Eintrag eines Elternteils für ein Kind an einem Tag.
/// Pro (Kind, Datum) maximal ein Eintrag — Geschwister-Doppelbuchungen
/// werden über die App-Logik geblockt.
model NotdienstAvailability {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
childId String
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
/// Eintragender User (für Audit / Erinnerungs-Cronjob).
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
date DateTime @db.Date
createdAt DateTime @default(now())
@@unique([childId, date])
@@index([kitaId, date])
@@index([userId])
@@map("notdienst_availabilities")
}
/// Eingeteilter Notdienst-Slot — Ergebnis der Plan-Generierung
/// bzw. manueller Bearbeitung durch den Koordinator.
model NotdienstAssignment {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
planId String
plan NotdienstPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
childId String
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
date DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
alerts NotdienstAlert[]
@@unique([planId, date])
@@index([kitaId, date])
@@map("notdienst_assignments")
}
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
model NotdienstAlert {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
assignmentId String
assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
/// Eingeteiltes Elternteil (Empfänger des Alarms).
parentUserId String
parentUser User @relation("NotdienstAlertParent", fields: [parentUserId], references: [id], onDelete: Cascade)
/// Auslösender Koordinator.
triggeredById String?
triggeredBy User? @relation("NotdienstAlertTrigger", fields: [triggeredById], references: [id], onDelete: SetNull)
/// Optional: kranke Fachkraft (Referenz für Reporting).
educatorId String?
educator Educator? @relation(fields: [educatorId], references: [id], onDelete: SetNull)
status NotdienstAlertStatus @default(PENDING)
/// Einmal-Token für den Bestätigungslink in der Alarm-Mail.
confirmationToken String @unique
triggeredAt DateTime @default(now())
confirmedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([kitaId])
@@index([assignmentId])
@@index([parentUserId])
@@map("notdienst_alerts")
}
// =====================================================================
// AUTH-HILFSTABELLE
// =====================================================================
/// Tokens für Passwort-Reset und E-Mail-Verifikation.
/// Kompatibel mit dem NextAuth-Schema, falls später Email-Provider aktiviert wird.
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
+675
View File
@@ -0,0 +1,675 @@
import {
InvitationStatus,
NotdienstAlertStatus,
NotdienstPlanStatus,
PrismaClient,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
import { hash } from "bcryptjs";
// =====================================================================
// Kita-Planer · Dev-Seed
// ---------------------------------------------------------------------
// Default: nicht destruktiv. Seed-Daten werden nur angelegt, wenn die DB
// noch keine Kita und keine User enthaelt.
//
// Reset: `npm run db:seed:reset` loescht gezielt die Demo-Daten und baut
// sie neu auf. Production wird nur mit ALLOW_DATABASE_SEED=true erlaubt.
// =====================================================================
const prisma = new PrismaClient();
const DEMO_KITA_SLUG = "waldameisen";
const DEFAULT_PASSWORD = "password123";
const PRIVACY_POLICY_VERSION = "2026-05-01";
const RESET_MODE = process.argv.includes("--reset");
const DEMO_USER_EMAILS = [
"super@kita-planer.local",
"admin@waldameisen.local",
"mueller@waldameisen.local",
"schmidt@waldameisen.local",
"yilmaz@waldameisen.local",
"pending@waldameisen.local",
];
const DEMO_VERIFICATION_TOKENS = [
"demo-pending-parent-token",
"demo-invite-token",
];
type SeedContext = Awaited<ReturnType<typeof createDemoUsers>>;
function dateOnly(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function addDays(date: Date, days: number) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return dateOnly(next);
}
function startOfMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function getPreviousMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth() - 1, 1);
}
function getLastDayOfPreviousMonth(date: Date) {
return dateOnly(new Date(date.getFullYear(), date.getMonth(), 0));
}
function getTargetMonth(date: Date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 1);
}
function getWorkingDays(year: number, month: number, limit?: number) {
const days: Date[] = [];
const cursor = new Date(year, month - 1, 1);
while (cursor.getMonth() === month - 1) {
const day = cursor.getDay();
if (day !== 0 && day !== 6) {
days.push(dateOnly(cursor));
}
cursor.setDate(cursor.getDate() + 1);
}
return typeof limit === "number" ? days.slice(0, limit) : days;
}
function isSeedAllowed() {
if (
process.env.NODE_ENV === "development" ||
process.env.ALLOW_DATABASE_SEED === "true"
) {
return true;
}
console.log(
"Seed uebersprungen: NODE_ENV ist nicht development. Setze ALLOW_DATABASE_SEED=true, wenn das bewusst gewuenscht ist.",
);
return false;
}
async function isDatabaseEmpty() {
const [kitaCount, userCount] = await Promise.all([
prisma.kita.count(),
prisma.user.count(),
]);
return kitaCount === 0 && userCount === 0;
}
async function resetDemoData() {
const demoUsers = await prisma.user.findMany({
where: {
OR: [
{ email: { in: DEMO_USER_EMAILS } },
{ kita: { slug: DEMO_KITA_SLUG } },
],
},
select: { id: true },
});
await prisma.verificationToken.deleteMany({
where: {
OR: [
{ token: { in: DEMO_VERIFICATION_TOKENS } },
{ identifier: { in: demoUsers.map((user) => user.id) } },
],
},
});
await prisma.kita.deleteMany({ where: { slug: DEMO_KITA_SLUG } });
await prisma.user.deleteMany({ where: { email: { in: DEMO_USER_EMAILS } } });
}
async function createDemoUsers(passwordHash: string, consentAt: Date) {
const superAdmin = await prisma.user.create({
data: {
email: "super@kita-planer.local",
passwordHash,
firstName: "Sys",
lastName: "Admin",
role: UserRole.SUPERADMIN,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
emailVerifiedAt: consentAt,
},
});
const kita = await prisma.kita.create({
data: {
name: "Waldameisen e.V.",
slug: DEMO_KITA_SLUG,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
notdienstMinPerChildPerMonth: 2,
notdienstReminderDaysBefore: 7,
},
});
const admin = await prisma.user.create({
data: {
kitaId: kita.id,
email: "admin@waldameisen.local",
passwordHash,
firstName: "Anna",
lastName: "Vorstand",
role: UserRole.ADMIN,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: consentAt,
emailVerifiedAt: consentAt,
phone: "+49 30 1000 1000",
street: "Kitaweg 1",
postalCode: "10115",
city: "Berlin",
},
});
const koordinator = await prisma.user.create({
data: {
kitaId: kita.id,
email: "mueller@waldameisen.local",
passwordHash,
firstName: "Maria",
lastName: "Mueller",
role: UserRole.KOORDINATOR,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: consentAt,
emailVerifiedAt: consentAt,
phone: "+49 30 1234 5678",
street: "Beispielweg 1",
postalCode: "10115",
city: "Berlin",
},
});
const elternSchmidt = await prisma.user.create({
data: {
kitaId: kita.id,
email: "schmidt@waldameisen.local",
passwordHash,
firstName: "Lukas",
lastName: "Schmidt",
role: UserRole.ELTERN,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
emailVerifiedAt: consentAt,
},
});
const elternYilmaz = await prisma.user.create({
data: {
kitaId: kita.id,
email: "yilmaz@waldameisen.local",
passwordHash,
firstName: "Aylin",
lastName: "Yilmaz",
role: UserRole.ELTERN,
privacyPolicyAcceptedAt: consentAt,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: consentAt,
emailVerifiedAt: consentAt,
phone: "+49 30 9876 5432",
street: "Parkstrasse 8",
postalCode: "10405",
city: "Berlin",
},
});
const pendingParent = await prisma.user.create({
data: {
kitaId: kita.id,
email: "pending@waldameisen.local",
passwordHash: "",
firstName: "Lena",
lastName: "Fischer",
role: UserRole.ELTERN,
},
});
return {
kita,
superAdmin,
admin,
koordinator,
elternSchmidt,
elternYilmaz,
pendingParent,
};
}
async function createChildren({
kita,
koordinator,
elternSchmidt,
elternYilmaz,
pendingParent,
}: SeedContext) {
const anna = await prisma.child.create({
data: {
kitaId: kita.id,
firstName: "Anna",
lastName: "Mueller",
dateOfBirth: new Date("2021-03-15"),
parentLinks: {
create: { kitaId: kita.id, userId: koordinator.id },
},
},
});
const ben = await prisma.child.create({
data: {
kitaId: kita.id,
firstName: "Ben",
lastName: "Mueller",
dateOfBirth: new Date("2023-07-22"),
notes: "Geschwisterkind von Anna.",
parentLinks: {
create: { kitaId: kita.id, userId: koordinator.id },
},
},
});
const clara = await prisma.child.create({
data: {
kitaId: kita.id,
firstName: "Clara",
lastName: "Schmidt",
dateOfBirth: new Date("2022-11-03"),
parentLinks: {
create: { kitaId: kita.id, userId: elternSchmidt.id },
},
},
});
const emil = await prisma.child.create({
data: {
kitaId: kita.id,
firstName: "Emil",
lastName: "Yilmaz",
dateOfBirth: new Date("2021-09-09"),
parentLinks: {
create: [
{ kitaId: kita.id, userId: elternYilmaz.id },
{ kitaId: kita.id, userId: elternSchmidt.id },
],
},
},
});
const nina = await prisma.child.create({
data: {
kitaId: kita.id,
firstName: "Nina",
lastName: "Fischer",
dateOfBirth: new Date("2022-05-30"),
parentLinks: {
create: { kitaId: kita.id, userId: pendingParent.id },
},
},
});
return { anna, ben, clara, emil, nina };
}
async function createEducators(kitaId: string) {
const sabine = await prisma.educator.create({
data: { kitaId, firstName: "Sabine", lastName: "Schulze" },
});
const petra = await prisma.educator.create({
data: { kitaId, firstName: "Petra", lastName: "Klein" },
});
const jonas = await prisma.educator.create({
data: { kitaId, firstName: "Jonas", lastName: "Wagner" },
});
const norbert = await prisma.educator.create({
data: {
kitaId,
firstName: "Norbert",
lastName: "Altmann",
active: false,
},
});
return { sabine, petra, jonas, norbert };
}
async function createParentDuties({
kita,
koordinator,
elternSchmidt,
elternYilmaz,
}: SeedContext) {
const waesche = await prisma.parentDuty.create({
data: {
kitaId: kita.id,
name: "Waeschedienst",
description: "Kita-Waesche waschen und zurueckbringen.",
},
});
const einkauf = await prisma.parentDuty.create({
data: {
kitaId: kita.id,
name: "Einkauf",
description: "Woechentlicher Einkauf fuer die Kita-Kueche.",
},
});
const garten = await prisma.parentDuty.create({
data: {
kitaId: kita.id,
name: "Gartendienst",
description: "Aussenbereich pflegen und Spielmaterial pruefen.",
},
});
await prisma.parentDutyAssignment.createMany({
data: [
{ kitaId: kita.id, dutyId: waesche.id, userId: elternSchmidt.id },
{ kitaId: kita.id, dutyId: einkauf.id, userId: koordinator.id },
{ kitaId: kita.id, dutyId: garten.id, userId: elternYilmaz.id },
],
});
}
async function createInvites({ kita, admin, pendingParent }: SeedContext) {
const expires = addDays(new Date(), 7);
await prisma.invitation.create({
data: {
kitaId: kita.id,
email: "invite@waldameisen.local",
role: UserRole.ELTERN,
token: "demo-invite-token",
status: InvitationStatus.PENDING,
expiresAt: expires,
invitedById: admin.id,
},
});
await prisma.verificationToken.create({
data: {
identifier: pendingParent.id,
token: "demo-pending-parent-token",
expires,
},
});
}
async function createTermine({
kita,
admin,
koordinator,
elternSchmidt,
elternYilmaz,
}: SeedContext) {
const now = new Date();
const elternabend = await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Elternabend Fruehling",
description: "Austausch zu Terminen, Notdienst und Sommerplanung.",
type: TerminType.ELTERNABEND,
status: TerminStatus.CONFIRMED,
startDate: addDays(now, 10),
endDate: addDays(now, 10),
allDay: false,
mitbringselListEnabled: true,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Teamtag",
description: "Die Kita bleibt wegen interner Fortbildung geschlossen.",
type: TerminType.TEAMTAG,
status: TerminStatus.CONFIRMED,
startDate: addDays(now, 18),
endDate: addDays(now, 18),
allDay: true,
createdById: admin.id,
approvedById: admin.id,
approvedAt: now,
},
});
await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Elterncafe im Garten",
description: "Anfrage eines Elternteils, noch nicht bestaetigt.",
type: TerminType.ELTERNCAFE,
status: TerminStatus.PENDING,
startDate: addDays(now, 24),
endDate: addDays(now, 24),
allDay: false,
createdById: elternSchmidt.id,
},
});
await prisma.termin.create({
data: {
kitaId: kita.id,
title: "Spontaner Ausflug",
description: "Beispiel fuer eine abgelehnte Terminanfrage.",
type: TerminType.SONSTIGES,
status: TerminStatus.REJECTED,
startDate: addDays(now, -6),
endDate: addDays(now, -6),
allDay: true,
createdById: elternYilmaz.id,
approvedById: koordinator.id,
approvedAt: addDays(now, -5),
rejectionReason: "Zu kurzfristig fuer die Gruppe.",
},
});
await prisma.mitbringselItem.createMany({
data: [
{
kitaId: kita.id,
terminId: elternabend.id,
userId: koordinator.id,
content: "Obstplatte",
},
{
kitaId: kita.id,
terminId: elternabend.id,
userId: elternSchmidt.id,
content: "Brot und Aufstrich",
},
],
});
}
async function createNotdienstData(
{ kita, koordinator, elternSchmidt, elternYilmaz }: SeedContext,
children: Awaited<ReturnType<typeof createChildren>>,
educators: Awaited<ReturnType<typeof createEducators>>,
) {
const today = dateOnly(new Date());
const currentMonth = startOfMonth(today);
const previousMonth = getPreviousMonth(today);
const previousAssignmentDate = getLastDayOfPreviousMonth(today);
const targetMonth = getTargetMonth(today);
const availabilityPairs = [
{ childId: children.anna.id, userId: koordinator.id },
{ childId: children.clara.id, userId: elternSchmidt.id },
{ childId: children.emil.id, userId: elternYilmaz.id },
{ childId: children.ben.id, userId: koordinator.id },
];
const targetWorkingDays = getWorkingDays(
targetMonth.getFullYear(),
targetMonth.getMonth() + 1,
12,
);
await prisma.notdienstAvailability.createMany({
data: targetWorkingDays.map((date, index) => {
const pair = availabilityPairs[index % availabilityPairs.length];
return {
kitaId: kita.id,
childId: pair.childId,
userId: pair.userId,
date,
};
}),
});
const currentPlan = await prisma.notdienstPlan.create({
data: {
kitaId: kita.id,
year: currentMonth.getFullYear(),
month: currentMonth.getMonth() + 1,
status: NotdienstPlanStatus.PUBLISHED,
createdById: koordinator.id,
publishedAt: new Date(),
},
});
await prisma.notdienstAssignment.create({
data: {
kitaId: kita.id,
planId: currentPlan.id,
childId: children.anna.id,
date: today,
},
});
const previousPlan = await prisma.notdienstPlan.create({
data: {
kitaId: kita.id,
year: previousMonth.getFullYear(),
month: previousMonth.getMonth() + 1,
status: NotdienstPlanStatus.PUBLISHED,
createdById: koordinator.id,
publishedAt: addDays(today, -10),
},
});
const pastAssignment = await prisma.notdienstAssignment.create({
data: {
kitaId: kita.id,
planId: previousPlan.id,
childId: children.clara.id,
date: previousAssignmentDate,
},
});
await prisma.notdienstAlert.create({
data: {
kitaId: kita.id,
assignmentId: pastAssignment.id,
parentUserId: elternSchmidt.id,
triggeredById: koordinator.id,
educatorId: educators.sabine.id,
status: NotdienstAlertStatus.CONFIRMED,
confirmationToken: "demo-confirmed-alert-token",
triggeredAt: previousAssignmentDate,
confirmedAt: previousAssignmentDate,
notes: "Beispiel-Alarm aus Seed-Daten.",
},
});
}
async function createDemoData() {
console.log("Seeding Kita-Planer demo data...");
const passwordHash = await hash(DEFAULT_PASSWORD, 12);
const consentAt = new Date();
const context = await createDemoUsers(passwordHash, consentAt);
const children = await createChildren(context);
const educators = await createEducators(context.kita.id);
await createParentDuties(context);
await createInvites(context);
await createTermine(context);
await createNotdienstData(context, children, educators);
printSummary(context, children);
}
async function seedIfEmpty() {
if (!(await isDatabaseEmpty())) {
console.log("Seed uebersprungen: Datenbank enthaelt bereits Kita- oder User-Daten.");
return;
}
await createDemoData();
}
function printSummary(
{
kita,
superAdmin,
admin,
koordinator,
elternSchmidt,
elternYilmaz,
pendingParent,
}: SeedContext,
children: Awaited<ReturnType<typeof createChildren>>,
) {
console.log("Seed complete");
console.log("");
console.log(` Kita: ${kita.name} (${kita.slug})`);
console.log("");
console.log(` Logins (Passwort jeweils: ${DEFAULT_PASSWORD})`);
console.log(` Superadmin: ${superAdmin.email}`);
console.log(` Admin: ${admin.email}`);
console.log(` Koordinator: ${koordinator.email}`);
console.log(` Eltern: ${elternSchmidt.email}`);
console.log(` Eltern: ${elternYilmaz.email}`);
console.log("");
console.log(
` Offener Invite: ${pendingParent.email} -> /invite/demo-pending-parent-token`,
);
console.log(
` Kinder: ${Object.values(children)
.map((child) => `${child.firstName} ${child.lastName}`)
.join(", ")}`,
);
}
async function main() {
if (!isSeedAllowed()) {
return;
}
if (RESET_MODE) {
console.log("Reset demo seed data...");
await resetDemoData();
await createDemoData();
return;
}
await seedIfEmpty();
}
main()
.catch((error) => {
console.error("Seed failed:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
export async function confirmAlertAction(token: string) {
try {
const alert = await prisma.notdienstAlert.findUnique({
where: { confirmationToken: token },
});
if (!alert) {
return { error: "Ungültiger Bestätigungs-Link." };
}
if (alert.status !== NotdienstAlertStatus.PENDING) {
return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." };
}
await prisma.notdienstAlert.update({
where: { id: alert.id },
data: {
status: NotdienstAlertStatus.CONFIRMED,
confirmedAt: new Date(),
},
});
revalidatePath("/dashboard");
return { success: true };
} catch (error: any) {
return { error: "Fehler bei der Bestätigung." };
}
}
@@ -0,0 +1,37 @@
"use client";
import { useTransition } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { confirmAlertAction } from "./actions";
export function AlertConfirmForm({ token }: { token: string }) {
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
startTransition(async () => {
const result = await confirmAlertAction(token);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Erfolgreich bestätigt!");
}
});
};
return (
<Button
size="lg"
className="w-full sm:w-auto font-semibold text-lg"
onClick={handleConfirm}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : null}
Ich bestätige! (Bin auf dem Weg)
</Button>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { notFound } from "next/navigation";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
import { NotdienstAlertStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AlertConfirmForm } from "./alert-confirm-form";
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
export default async function AlertPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const alert = await prisma.notdienstAlert.findUnique({
where: { confirmationToken: token },
include: {
parentUser: true,
kita: true,
assignment: { include: { child: true } },
},
});
if (!alert) {
notFound();
}
const isPending = alert.status === NotdienstAlertStatus.PENDING;
const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED;
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-md shadow-lg border-primary/20">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
{isPending ? (
<BellRing className="h-8 w-8 animate-pulse text-destructive" />
) : isConfirmed ? (
<ShieldCheck className="h-8 w-8 text-success" />
) : (
<XCircle className="h-8 w-8 text-muted-foreground" />
)}
</div>
<CardTitle className="text-2xl">
{isPending
? "Notdienst Einsatz!"
: isConfirmed
? "Einsatz bestätigt"
: "Alarm abgebrochen"}
</CardTitle>
<CardDescription className="text-base mt-2">
Kita {alert.kita.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-4">
<div className="rounded-lg bg-background border p-4 text-center space-y-1">
<p className="text-sm text-muted-foreground">Datum</p>
<p className="font-semibold text-lg">
{format(alert.assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de,
})}
</p>
<div className="h-2" />
<p className="text-sm text-muted-foreground">Eingeteiltes Kind</p>
<p className="font-medium">
{alert.assignment.child.firstName}{" "}
{alert.assignment.child.lastName}
</p>
</div>
<div className="text-center">
{isPending ? (
<>
<p className="mb-6 text-sm text-muted-foreground">
Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich
krankgemeldet und du bist heute für den Notdienst eingeteilt.
Bitte bestätige deinen Einsatz schnellstmöglich!
</p>
<AlertConfirmForm token={token} />
</>
) : isConfirmed ? (
<p className="text-sm text-muted-foreground">
Danke, {alert.parentUser.firstName}! Dein Einsatz wurde
erfolgreich bestätigt. Die Kita rechnet mit dir.
</p>
) : (
<p className="text-sm text-muted-foreground">
Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht
einspringen.
</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { handlers } from "@/auth";
// NextAuth v5: Die fertigen Handler werden direkt re-exportiert.
export const { GET, POST } = handlers;
+125
View File
@@ -0,0 +1,125 @@
"use server";
import crypto from "crypto";
import { createElement } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { revalidatePath } from "next/cache";
import { NotdienstAlertStatus, UserRole } from "@prisma/client";
import { AlertEmail } from "@/emails/AlertEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
export async function triggerAlertAction(
assignmentId: string,
parentUserId: string,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
try {
const [existing, assignment] = await Promise.all([
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
prisma.notdienstAlert.findFirst({
where: { assignmentId, kitaId },
}),
prisma.notdienstAssignment.findFirst({
where: { id: assignmentId, kitaId },
include: {
child: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
}),
]);
if (existing) {
return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." };
}
if (!assignment) {
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
}
if (!parentUserId) {
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
const parentLink = await prisma.childParent.findFirst({
where: {
kitaId,
childId: assignment.child.id,
userId: parentUserId,
},
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!parentLink) {
return { error: "Das Elternteil passt nicht zu dieser Einteilung." };
}
const token = crypto.randomUUID();
await prisma.notdienstAlert.create({
data: {
kitaId,
assignmentId,
parentUserId,
triggeredById: session.user.id,
status: NotdienstAlertStatus.PENDING,
confirmationToken: token,
},
});
const alertUrl = `${BASE_URL}/alert/${token}`;
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
locale: de,
});
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
const emailResult = await sendAppEmail({
to: parentLink.user.email,
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
react: createElement(AlertEmail, {
date: dateLabel,
childName,
confirmLink: alertUrl,
}),
});
revalidatePath("/dashboard");
if (!emailResult.success) {
return {
error: `Alarm wurde angelegt, aber die E-Mail konnte nicht versendet werden: ${emailResult.error}`,
};
}
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler beim Auslösen des Alarms." };
}
}
+106
View File
@@ -0,0 +1,106 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Contact, Mail, Phone, Baby } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export default async function AdressbuchPage() {
const session = await requireKitaSession();
// Fetch only users who opted in to the directory
const users = await prisma.user.findMany({
where: {
kitaId: session.user.kitaId,
directoryOptInAt: { not: null },
},
include: {
childLinks: {
include: { child: true },
},
dutyAssignments: {
include: { duty: true },
},
},
orderBy: { lastName: "asc" },
});
return (
<div className="flex h-full flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
<p className="text-muted-foreground">
Kontaktinformationen aller Eltern, die der Freigabe zugestimmt haben.
</p>
</div>
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<Contact className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="mt-4 text-lg font-semibold">Keine Kontakte</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
Bisher hat niemand der Veröffentlichung im Adressbuch zugestimmt.
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<Card key={u.id} className="flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="flex justify-between items-start">
<span className="text-lg">
{u.firstName} {u.lastName}
</span>
{u.role === "ADMIN" || u.role === "KOORDINATOR" ? (
<Badge variant="secondary" className="text-[10px]">Vorstand</Badge>
) : null}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm flex-1">
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" />
<a href={`mailto:${u.email}`} className="hover:text-primary transition-colors truncate">
{u.email}
</a>
</div>
{u.phone && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" />
<a href={`tel:${u.phone}`} className="hover:text-primary transition-colors">
{u.phone}
</a>
</div>
)}
{u.childLinks.length > 0 && (
<div className="flex items-start gap-2 text-muted-foreground mt-1">
<Baby className="h-4 w-4 shrink-0 mt-0.5" />
<div className="flex flex-wrap gap-1">
{u.childLinks.map(link => (
<span key={link.child.id} className="bg-muted px-1.5 py-0.5 rounded-md text-xs">
{link.child.firstName}
</span>
))}
</div>
</div>
)}
{u.dutyAssignments.length > 0 && (
<div className="mt-auto pt-3 border-t">
<span className="text-xs font-medium text-muted-foreground mb-1 block">Ämter / Dienste:</span>
<div className="flex flex-wrap gap-1">
{u.dutyAssignments.map((assignment) => (
<Badge key={assignment.duty.id} variant="outline" className="bg-primary/5 text-primary border-primary/20">
{assignment.duty.name}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useTransition } from "react";
import { Loader2, BellRing } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { triggerAlertAction } from "./actions";
export function AlertButton({
assignmentId,
parentUserId,
}: {
assignmentId: string;
parentUserId: string;
}) {
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, parentUserId);
if ("error" in result && result.error) {
toast.error(result.error);
} else {
toast.success("Alarm erfolgreich ausgelöst. E-Mail wurde versendet.");
}
});
};
return (
<Button variant="destructive" onClick={handleAlert} disabled={isPending}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<BellRing className="mr-2 h-4 w-4" />
)}
Alarm auslösen
</Button>
);
}
@@ -0,0 +1,202 @@
"use client";
import { useState, useTransition } from "react";
import { Educator } from "@prisma/client";
import { Plus, Pencil, Trash2 } 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 { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { createEducator, updateEducator, deleteEducator } from "../actions";
export function ErzieherList({ educators }: { educators: Educator[] }) {
const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<Educator | null>(null);
const [isPending, startTransition] = useTransition();
const handleCreate = (formData: FormData) => {
const data = {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
};
startTransition(async () => {
const res = await createEducator(data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("ErzieherIn angelegt.");
setOpenCreate(false);
}
});
};
const handleUpdate = (formData: FormData) => {
if (!editingEducator) return;
const data = {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
};
startTransition(async () => {
const res = await updateEducator(editingEducator.id, data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Daten aktualisiert.");
setEditingEducator(null);
}
});
};
const handleDelete = (id: string) => {
if (!confirm("Wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) return;
startTransition(async () => {
const res = await deleteEducator(id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("ErzieherIn gelöscht.");
}
});
};
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
ErzieherIn anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleCreate}>
<DialogHeader>
<DialogTitle>Neue(n) ErzieherIn anlegen</DialogTitle>
<DialogDescription>
Erfasse die Stammdaten des Kita-Personals.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="firstName">Vorname</Label>
<Input id="firstName" name="firstName" required />
</div>
<div className="grid gap-2">
<Label htmlFor="lastName">Nachname</Label>
<Input id="lastName" name="lastName" required />
</div>
<div className="flex items-center gap-2 mt-2">
<Switch id="active" name="active" defaultChecked />
<Label htmlFor="active">Aktiv (Arbeitet aktuell hier)</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Speichern</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="rounded-md border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground h-24">
Keine Einträge gefunden.
</TableCell>
</TableRow>
) : (
educators.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="font-medium">{ed.firstName}</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => handleDelete(ed.id)} disabled={isPending}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
<DialogContent>
<form action={handleUpdate}>
<DialogHeader>
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
</DialogHeader>
{editingEducator && (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-firstName">Vorname</Label>
<Input id="edit-firstName" name="firstName" defaultValue={editingEducator.firstName} required />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-lastName">Nachname</Label>
<Input id="edit-lastName" name="lastName" defaultValue={editingEducator.lastName} required />
</div>
<div className="flex items-center gap-2 mt-2">
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
<Label htmlFor="edit-active">Aktiv</Label>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const educatorSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich"),
lastName: z.string().min(1, "Nachname ist erforderlich"),
active: z.boolean().default(true),
});
export async function createEducator(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.create({
data: {
kitaId: session.user.kitaId!,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function updateEducator(id: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.update({
where: {
id,
kitaId: session.user.kitaId!,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function deleteEducator(id: string) {
const session = await requireRole([UserRole.ADMIN]);
try {
await prisma.educator.delete({
where: {
id,
kitaId: session.user.kitaId!,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
+29
View File
@@ -0,0 +1,29 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { ErzieherList } from "./_components/erzieher-list";
export const metadata = { title: "ErzieherInnen · Kita-Planer" };
export default async function ErzieherPage() {
const session = await requireRole([UserRole.ADMIN]);
const educators = await prisma.educator.findMany({
where: { kitaId: session.user.kitaId! },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
});
return (
<div className="flex h-full flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">ErzieherInnen-Verwaltung</h1>
<p className="text-muted-foreground">
Verwalte die Stammdaten des Kita-Personals (nur für den Vorstand sichtbar).
</p>
</div>
<ErzieherList educators={educators} />
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use server";
import crypto from "crypto";
import { createElement } from "react";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireRole } from "@/lib/auth-utils";
import { InviteEmail } from "@/emails/InviteEmail";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
// =====================================================================
// /dashboard/families · Server Actions
// ---------------------------------------------------------------------
// addFamilyAction: Erstellt Elternteil + Kinder + VerificationToken in
// einer atomaren Prisma-Transaktion. Die kitaId kommt ausschließlich
// aus der validierten Session — nie aus dem Formular-Input.
// =====================================================================
const parentSchema = z.object({
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase()
.trim(),
childCount: z.coerce
.number()
.int()
.min(1, "Mindestens ein Kind erforderlich.")
.max(10),
});
const childSchema = z.object({
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
});
export type AddFamilyState = {
errors?: {
firstName?: string[];
lastName?: string[];
email?: string[];
children?: string[];
_form?: string[];
};
success?: boolean;
};
const PRIVACY_POLICY_VERSION = "2026-05-01";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
export async function addFamilyAction(
_prev: AddFamilyState,
formData: FormData,
): Promise<AddFamilyState> {
// ── 1. Nur Admins dürfen Familien anlegen ──────────────────────────
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
// ── 2. Parent-Felder validieren ────────────────────────────────────
const parsedParent = parentSchema.safeParse(Object.fromEntries(formData));
if (!parsedParent.success) {
return { errors: parsedParent.error.flatten().fieldErrors };
}
const { firstName, lastName, email, childCount } = parsedParent.data;
// ── 3. Kinder-Felder validieren ────────────────────────────────────
const childrenRaw: { firstName: string; lastName: string }[] = [];
for (let i = 0; i < childCount; i++) {
const parsed = childSchema.safeParse({
firstName: formData.get(`childFirstName_${i}`),
lastName: formData.get(`childLastName_${i}`),
});
if (!parsed.success) {
return {
errors: { children: [`Kind ${i + 1}: ${Object.values(parsed.error.flatten().fieldErrors).flat().join(", ")}`] },
};
}
childrenRaw.push(parsed.data);
}
// ── 4. Datenbank-Transaktion ───────────────────────────────────────
// kitaId kommt ausschließlich aus der Session (Mandanten-Isolation!).
// SUPERADMIN hat keine kitaId → dieser Pfad sollte nie erreicht werden,
// aber wir prüfen explizit, um den Typ zu narrowen.
const kitaId = session.user.kitaId;
if (!kitaId) {
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { errors: { _form: [mailConfigError] } };
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { errors: { _form: ["Kita wurde nicht gefunden."] } };
}
const parentName = `${firstName} ${lastName}`;
const token = crypto.randomUUID();
const inviteUrl = `${BASE_URL}/invite/${token}`;
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
try {
await prisma.$transaction(async (tx) => {
// 4a. Elternteil anlegen (kein Passwort → leerer passwordHash)
const parent = await tx.user.create({
data: {
email,
firstName,
lastName,
passwordHash: "", // wird beim Invite-Einlösen gesetzt
role: UserRole.ELTERN,
kitaId,
},
});
// 4b. Kinder anlegen + mit Elternteil verknüpfen
for (const child of childrenRaw) {
const createdChild = await tx.child.create({
data: {
kitaId,
firstName: child.firstName,
lastName: child.lastName,
},
});
await tx.childParent.create({
data: {
kitaId,
childId: createdChild.id,
userId: parent.id,
},
});
}
// 4c. Einladungs-Token erstellen
// identifier = userId (kein PII im Token selbst)
await tx.verificationToken.create({
data: {
identifier: parent.id,
token,
expires,
},
});
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
};
}
throw err;
}
const emailResult = await sendAppEmail({
to: email,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName,
kitaName: kita.name,
inviteLink: inviteUrl,
}),
});
if (!emailResult.success) {
return {
errors: {
_form: [
`Familie wurde angelegt, aber die Einladung konnte nicht versendet werden: ${emailResult.error}`,
],
},
};
}
return { success: true };
}
@@ -0,0 +1,225 @@
"use client";
import { useActionState, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { addFamilyAction, type AddFamilyState } from "./actions";
const initialState: AddFamilyState = {};
// =====================================================================
// AddFamilyDialog · Client Component
// ---------------------------------------------------------------------
// Verwaltet:
// • Dialog-Open/Close-State
// • Dynamische Kinderliste (min. 1, max. 10)
// • useActionState → Server-Action-Fehler anzeigen
// • Bei Erfolg (state.success) Dialog automatisch schließen
// =====================================================================
export function AddFamilyDialog() {
const [open, setOpen] = useState(false);
const [childCount, setChildCount] = useState(1);
const [state, formAction, pending] = useActionState(
addFamilyAction,
initialState,
);
// Dialog schließen, wenn die Action erfolgreich war
if (state.success && open) {
setOpen(false);
}
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
// State beim Schließen zurücksetzen würde useActionState erfordern —
// stattdessen schließen wir einfach und der State bleibt bis zur nächsten
// Aktion erhalten (unkritisch).
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Familie hinzufügen
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Familie hinzufügen</DialogTitle>
<DialogDescription>
Lege das Elternteil und die zugehörigen Kinder an. Der Elternteil
erhält einen Einladungslink per E-Mail, um sein Passwort zu setzen.
</DialogDescription>
</DialogHeader>
<form action={formAction} className="space-y-6">
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
<input type="hidden" name="childCount" value={childCount} />
{/* ── Elternteil ─────────────────────────────────────────── */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Elternteil</legend>
<div className="grid grid-cols-2 gap-3">
<FormField
id="firstName"
name="firstName"
label="Vorname"
autoComplete="given-name"
error={state.errors?.firstName?.[0]}
/>
<FormField
id="lastName"
name="lastName"
label="Nachname"
autoComplete="family-name"
error={state.errors?.lastName?.[0]}
/>
</div>
<FormField
id="email"
name="email"
type="email"
label="E-Mail-Adresse"
autoComplete="email"
error={state.errors?.email?.[0]}
/>
</fieldset>
{/* ── Kinder ─────────────────────────────────────────────── */}
<fieldset className="space-y-3">
<div className="flex items-center justify-between">
<legend className="text-sm font-semibold">
Kinder ({childCount})
</legend>
{childCount < 10 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setChildCount((c) => c + 1)}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Kind hinzufügen
</Button>
)}
</div>
{Array.from({ length: childCount }).map((_, i) => (
<div
key={i}
className="relative rounded-md border p-4"
>
{childCount > 1 && (
<button
type="button"
onClick={() => {
// Kind entfernen — childCount reduzieren reicht, da die
// Felder nach Index benannt sind und der letzte entfernt wird.
setChildCount((c) => c - 1);
}}
className="absolute right-3 top-3 rounded p-0.5 text-muted-foreground hover:text-destructive"
aria-label={`Kind ${i + 1} entfernen`}
>
<Trash2 className="h-4 w-4" />
</button>
)}
<p className="mb-3 text-xs font-medium text-muted-foreground">
Kind {i + 1}
</p>
<div className="grid grid-cols-2 gap-3">
<FormField
id={`childFirstName_${i}`}
name={`childFirstName_${i}`}
label="Vorname"
/>
<FormField
id={`childLastName_${i}`}
name={`childLastName_${i}`}
label="Nachname"
/>
</div>
</div>
))}
{state.errors?.children?.[0] && (
<p className="text-xs text-destructive">
{state.errors.children[0]}
</p>
)}
</fieldset>
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={pending}
>
Abbrechen
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Wird angelegt…" : "Familie anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ── Hilfskomponente ────────────────────────────────────────────────────
function FormField({
id,
name,
label,
type = "text",
autoComplete,
error,
}: {
id: string;
name: string;
label: string;
type?: string;
autoComplete?: string;
error?: string;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
name={name}
type={type}
autoComplete={autoComplete}
required
aria-invalid={!!error}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { revalidatePath } from "next/cache";
import { UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const dutySchema = z.object({
name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50),
description: z.string().optional(),
});
export async function createDuty(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const parsed = dutySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.parentDuty.create({
data: {
kitaId: session.user.kitaId!,
name: parsed.data.name,
description: parsed.data.description,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten (evtl. existiert der Name bereits)." };
}
}
export async function deleteDuty(dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.parentDuty.delete({
where: {
id: dutyId,
kitaId: session.user.kitaId!,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function assignDuty(userId: string, dutyId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
// Check if assignment already exists
const existing = await prisma.parentDutyAssignment.findUnique({
where: {
dutyId_userId: { dutyId, userId },
},
});
if (existing) {
return { error: "Der Nutzer hat dieses Amt bereits." };
}
await prisma.parentDutyAssignment.create({
data: {
kitaId: session.user.kitaId!,
userId: userId,
dutyId: dutyId,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Zuweisen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function removeDutyAssignment(assignmentId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.parentDutyAssignment.delete({
where: {
id: assignmentId,
kitaId: session.user.kitaId!,
},
});
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Entfernen des Amtes:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, X, Shield, Trash2 } from "lucide-react";
import { ParentDuty, ParentDutyAssignment, User } from "@prisma/client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
type DutyWithAssignments = ParentDuty & {
assignments: ParentDutyAssignment[];
};
export function DutyManager({
user,
allDuties,
userAssignments,
}: {
user: User;
allDuties: DutyWithAssignments[];
userAssignments: (ParentDutyAssignment & { duty: ParentDuty })[];
}) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleCreateDuty = (formData: FormData) => {
const name = formData.get("name") as string;
if (!name) return;
startTransition(async () => {
const res = await createDuty({ name });
if (res.error) {
toast.error(res.error);
} else {
toast.success("Amt erstellt.");
}
});
};
const handleAssign = (dutyId: string) => {
startTransition(async () => {
const res = await assignDuty(user.id, dutyId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Amt zugewiesen.");
}
});
};
const handleRemove = (assignmentId: string) => {
startTransition(async () => {
const res = await removeDutyAssignment(assignmentId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Zuweisung entfernt.");
}
});
};
const handleDeleteDuty = (dutyId: string) => {
startTransition(async () => {
const res = await deleteDuty(dutyId);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Amt komplett gelöscht.");
}
});
};
const unassignedDuties = allDuties.filter(
(d) => !userAssignments.some((a) => a.dutyId === d.id)
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1">
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">Ämter</span>
{userAssignments.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-4">
{userAssignments.length}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Ämter verwalten</DialogTitle>
<DialogDescription>
Ämter und Dienste für {user.firstName} {user.lastName}.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 py-4">
{/* Current Assignments */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold">Aktuelle Ämter</h4>
{userAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine Ämter zugewiesen.</p>
) : (
<div className="flex flex-wrap gap-2">
{userAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="gap-1 pr-1 bg-primary">
{assignment.duty.name}
<button
className="ml-1 rounded-full hover:bg-primary-foreground/20 p-0.5"
onClick={() => handleRemove(assignment.id)}
disabled={isPending}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Assign new duty */}
<div className="flex flex-col gap-2 border-t pt-4">
<h4 className="text-sm font-semibold">Vorhandenes Amt zuweisen</h4>
{unassignedDuties.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Keine weiteren Ämter verfügbar.</p>
) : (
<div className="flex flex-wrap gap-2">
{unassignedDuties.map((duty) => (
<Badge
key={duty.id}
variant="outline"
className="cursor-pointer hover:bg-muted gap-1 pr-1 group relative"
onClick={() => handleAssign(duty.id)}
>
<Plus className="h-3 w-3" />
{duty.name}
{/* Only show delete if no one is assigned to it across the entire DB */}
{duty.assignments.length === 0 && (
<button
className="ml-1 rounded-full hover:bg-destructive/20 text-destructive p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleDeleteDuty(duty.id);
}}
disabled={isPending}
title="Amt löschen"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</Badge>
))}
</div>
)}
</div>
{/* Create new duty */}
<div className="flex flex-col gap-2 border-t pt-4">
<h4 className="text-sm font-semibold">Neues Amt anlegen</h4>
<form action={handleCreateDuty} className="flex gap-2">
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
<Button size="sm" className="h-8" disabled={isPending}>
Erstellen
</Button>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AddFamilyDialog } from "./add-family-dialog";
import { DutyManager } from "./duty-manager";
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
// =====================================================================
// /dashboard/families · Server Component
// ---------------------------------------------------------------------
// Zeigt alle Elternteile (ELTERN) und deren Kinder für die aktuelle
// kitaId. Tenant-Filter ist garantiert: kitaId aus requireRole-Session.
// =====================================================================
export default async function FamiliesPage() {
// Guard: Nur Admins (und Koordinatoren) dürfen diese Seite sehen
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
// Alle ELTERN-User der Kita mit ihren verknüpften Kindern laden
// kitaId IMMER aus der Session — nie aus URL/Params
const families = await prisma.user.findMany({
where: {
kitaId: session.user.kitaId!,
role: UserRole.ELTERN,
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
passwordHash: true, // "" → Invite ausstehend; sonst aktiv
emailVerifiedAt: true,
childLinks: {
select: {
child: {
select: { id: true, firstName: true, lastName: true },
},
},
},
dutyAssignments: {
include: { duty: true },
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
});
const allDuties = await prisma.parentDuty.findMany({
where: { kitaId: session.user.kitaId! },
include: { assignments: true },
orderBy: { name: "asc" },
});
return (
<div className="px-8 py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Familienverwaltung
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{families.length === 0
? "Noch keine Familien angelegt."
: `${families.length} ${families.length === 1 ? "Familie" : "Familien"} · ${families.reduce((acc, f) => acc + f.childLinks.length, 0)} Kinder`}
</p>
</div>
<AddFamilyDialog />
</div>
{families.length === 0 ? (
<EmptyState />
) : (
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>Elternteil</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Kinder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{families.map((family) => {
const isActive = !!family.emailVerifiedAt;
const children = family.childLinks.map((l) => l.child);
return (
<TableRow key={family.id}>
<TableCell className="font-medium">
{family.firstName} {family.lastName}
</TableCell>
<TableCell className="text-muted-foreground">
{family.email}
</TableCell>
<TableCell>
{children.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
) : (
<div className="flex flex-wrap gap-1">
{children.map((child) => (
<span
key={child.id}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium"
>
{child.firstName} {child.lastName}
</span>
))}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={isActive ? "success" : "warning"}>
{isActive ? "Aktiv" : "Eingeladen"}
</Badge>
</TableCell>
<TableCell className="text-right">
<DutyManager
user={family as any}
allDuties={allDuties as any}
userAssignments={family.dutyAssignments}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background px-6 py-16 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg
className="h-6 w-6 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
</div>
<h3 className="mb-1 font-semibold">Noch keine Familien</h3>
<p className="mb-4 max-w-sm text-sm text-muted-foreground">
Lege die erste Familie an und lade das Elternteil per Link ein, sein
Passwort zu setzen.
</p>
<AddFamilyDialog />
</div>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { Plus } 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 { createTerminAdmin } from "../actions";
import { Textarea } from "@/components/ui/textarea";
export function AdminTerminModal() {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleAction = (formData: FormData) => {
const data = {
title: formData.get("title") as string,
description: formData.get("description") as string,
type: formData.get("type") as TerminType,
startDate: new Date(formData.get("startDate") as string).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(),
allDay: formData.get("allDay") === "on",
};
startTransition(async () => {
const res = await createTerminAdmin(data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Termin wurde direkt angelegt!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 bg-primary">
<Plus className="h-4 w-4" />
Termin direkt anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleAction}>
<DialogHeader>
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
<DialogDescription>
Dieser Termin wird sofort freigegeben und ist für alle sichtbar.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Sommerfest" />
</div>
<div className="grid gap-2">
<Label htmlFor="description">Beschreibung (Optional)</Label>
<Textarea id="description" name="description" placeholder="Details zum Termin..." />
</div>
<div className="grid gap-2">
<Label htmlFor="type">Kategorie</Label>
<select
id="type"
name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
<option value={TerminType.ELTERNABEND}>Elternabend</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
<option value={TerminType.SONSTIGES}>Sonstiges</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required />
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required />
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Speichern..." : "Termin erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState, useTransition } from "react";
import { MitbringselItem } from "@prisma/client";
import { Trash2, Plus, Utensils } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addMitbringsel, deleteMitbringsel } from "../actions";
type ItemWithUser = MitbringselItem & {
user: { firstName: string; lastName: string };
};
export function MitbringselList({
terminId,
items,
currentUserId,
isAdmin,
}: {
terminId: string;
items: ItemWithUser[];
currentUserId: string;
isAdmin: boolean;
}) {
const [content, setContent] = useState("");
const [isPending, startTransition] = useTransition();
const handleAdd = () => {
if (!content.trim()) return;
startTransition(async () => {
const res = await addMitbringsel({ terminId, content });
if (res.error) {
toast.error(res.error);
} else {
toast.success("Eintrag hinzugefügt.");
setContent("");
}
});
};
const handleDelete = (id: string) => {
startTransition(async () => {
const res = await deleteMitbringsel(id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Eintrag gelöscht.");
}
});
};
return (
<div className="flex flex-col gap-3 rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-2 font-medium text-sm mb-1">
<Utensils className="h-4 w-4 text-muted-foreground" />
Mitbring-Liste
</div>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Noch keine Einträge vorhanden.</p>
) : (
items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-2 rounded-sm bg-background p-2 text-sm shadow-sm"
>
<div className="flex flex-col">
<span className="font-medium text-xs text-primary">
{item.user.firstName} {item.user.lastName}
</span>
<span>{item.content}</span>
</div>
{(isAdmin || item.userId === currentUserId) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => handleDelete(item.id)}
disabled={isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))
)}
</div>
<div className="flex gap-2 mt-1">
<Input
placeholder="Was bringst du mit?"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
disabled={isPending}
className="h-8 text-sm"
/>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleAdd}
disabled={!content.trim() || isPending}
>
<Plus className="h-4 w-4 mr-1" />
Hinzufügen
</Button>
</div>
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Check, X, Clock, CalendarIcon } from "lucide-react";
import { toast } from "sonner";
import { Termin, User } from "@prisma/client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { approveTermin, rejectTermin } from "../actions";
type PendingTermin = Termin & {
createdBy: { firstName: string; lastName: string } | null;
};
export function PendingAnfragen({ termine }: { termine: PendingTermin[] }) {
if (termine.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<CalendarIcon className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Keine ausstehenden Anfragen</h3>
<p className="text-sm text-muted-foreground">
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{termine.map((termin) => (
<PendingTerminCard key={termin.id} termin={termin} />
))}
</div>
);
}
function PendingTerminCard({ termin }: { termin: PendingTermin }) {
const [isPending, startTransition] = useTransition();
const handleApprove = () => {
startTransition(async () => {
const res = await approveTermin(termin.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Anfrage freigegeben.");
}
});
};
const handleReject = () => {
// In a real app, you might want to ask for a rejection reason in a prompt/modal.
startTransition(async () => {
const res = await rejectTermin(termin.id);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Anfrage abgelehnt.");
}
});
};
return (
<Card>
<CardContent className="flex items-center justify-between p-4 sm:p-6">
<div className="flex flex-col gap-1">
<div className="font-semibold">{termin.title}</div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && `${format(termin.startDate, "p", { locale: de })}`}
</div>
<div className="text-sm font-medium text-primary mt-1">
Angefragt von: {termin.createdBy?.firstName} {termin.createdBy?.lastName}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground border-destructive/20"
onClick={handleReject}
disabled={isPending}
>
<X className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Ablehnen</span>
</Button>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleApprove}
disabled={isPending}
>
<Check className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Freigeben</span>
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,214 @@
"use client";
import { Termin, MitbringselItem, TerminType, TerminStatus } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Calendar, Clock, MapPin, AlignLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MitbringselList } from "./mitbringsel-list";
import { toggleMitbringselList } from "../actions";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTransition } from "react";
import { toast } from "sonner";
type TerminWithItems = Termin & {
mitbringselItems?: (MitbringselItem & {
user: { firstName: string; lastName: string };
})[];
};
export function TerminList({
termine,
userId,
isAdmin,
}: {
termine: TerminWithItems[];
userId: string;
isAdmin: boolean;
}) {
if (termine.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-in fade-in-50">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<Calendar className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="mt-4 text-lg font-semibold">Keine Termine</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
Es stehen aktuell keine Termine an.
</p>
</div>
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{termine.map((termin) => (
<TerminCard
key={termin.id}
termin={termin}
userId={userId}
isAdmin={isAdmin}
/>
))}
</div>
);
}
function TerminCard({
termin,
userId,
isAdmin,
}: {
termin: TerminWithItems;
userId: string;
isAdmin: boolean;
}) {
const [isPending, startTransition] = useTransition();
const handleToggleMitbringsel = (enabled: boolean) => {
startTransition(async () => {
const result = await toggleMitbringselList(termin.id, enabled);
if (result.error) {
toast.error(result.error);
} else {
toast.success(
enabled
? "Mitbring-Liste aktiviert"
: "Mitbring-Liste deaktiviert"
);
}
});
};
return (
<Card className="flex flex-col overflow-hidden relative">
{termin.status === TerminStatus.PENDING && (
<div className="absolute top-0 right-0 bg-yellow-500 text-white text-xs font-bold px-2 py-1 rounded-bl-lg z-10">
Ausstehend
</div>
)}
<CardHeader className="pb-3">
<div className="flex justify-between items-start mb-2 gap-2">
<Badge variant={getBadgeVariant(termin.type)} className={getBadgeColor(termin.type)}>
{getTypeLabel(termin.type)}
</Badge>
</div>
<CardTitle className="line-clamp-2 leading-tight">{termin.title}</CardTitle>
<CardDescription className="flex items-center gap-1 mt-1 text-sm">
<Clock className="h-3.5 w-3.5" />
{format(termin.startDate, "PP", { locale: de })}
{!termin.allDay && (
<>
{" • "}
{format(termin.startDate, "p", { locale: de })} -{" "}
{format(termin.endDate, "p", { locale: de })}
</>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-4">
{termin.description && (
<div className="flex items-start gap-2 text-sm text-muted-foreground mt-2">
<AlignLeft className="h-4 w-4 shrink-0 mt-0.5" />
<p className="line-clamp-3 whitespace-pre-wrap">{termin.description}</p>
</div>
)}
{/* Admin Toggle for Mitbringsel-List */}
{isAdmin && termin.status === TerminStatus.CONFIRMED && (
<div className="flex items-center space-x-2 mt-6 pt-4 border-t">
<Switch
id={`mitbringsel-${termin.id}`}
checked={termin.mitbringselListEnabled}
onCheckedChange={handleToggleMitbringsel}
disabled={isPending}
/>
<Label htmlFor={`mitbringsel-${termin.id}`} className="text-xs">
Mitbring-Liste aktiv
</Label>
</div>
)}
{/* Mitbringsel-List UI */}
{termin.mitbringselListEnabled && termin.status === TerminStatus.CONFIRMED && (
<div className="mt-4">
<MitbringselList
terminId={termin.id}
items={termin.mitbringselItems || []}
currentUserId={userId}
isAdmin={isAdmin}
/>
</div>
)}
</CardContent>
</Card>
);
}
function getBadgeVariant(type: TerminType): "default" | "secondary" | "destructive" | "outline" {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "default";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "destructive";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "secondary";
default:
return "outline";
}
}
function getBadgeColor(type: TerminType): string {
switch (type) {
case TerminType.KITA_FEST:
case TerminType.MITMACH_TAG:
return "bg-amber-500 hover:bg-amber-600 text-white border-transparent";
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent";
case TerminType.GEBURTSTAG_INTERN:
case TerminType.GEBURTSTAG_EXTERN:
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent";
case TerminType.ELTERNABEND:
case TerminType.MITGLIEDERVERSAMMLUNG:
case TerminType.ELTERNCAFE:
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent";
default:
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700";
}
}
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 (Kita geschlossen)";
case TerminType.ELTERNABEND:
return "Elternabend";
case TerminType.MITGLIEDERVERSAMMLUNG:
return "Mitgliederversammlung";
case TerminType.ELTERNCAFE:
return "Elterncafe";
case TerminType.GEBURTSTAG_INTERN:
return "Geburtstag (Intern)";
case TerminType.GEBURTSTAG_EXTERN:
return "Raumanfrage (Extern)";
default:
return "Sonstiges";
}
}
@@ -0,0 +1,122 @@
"use client";
import { useState, useTransition } from "react";
import { TerminType } from "@prisma/client";
import { CalendarPlus } 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 { createTerminRequest } from "../actions";
export function TerminRequestModal() {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleAction = (formData: FormData) => {
const data = {
title: formData.get("title") as string,
type: formData.get("type") as TerminType,
startDate: new Date(formData.get("startDate") as string).toISOString(),
endDate: new Date(formData.get("endDate") as string).toISOString(),
allDay: formData.get("allDay") === "on",
};
startTransition(async () => {
const res = await createTerminRequest(data);
if (res.error) {
toast.error(res.error);
} else {
toast.success("Terminanfrage wurde erfolgreich gestellt!");
setOpen(false);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<CalendarPlus className="h-4 w-4" />
Termin anfragen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleAction}>
<DialogHeader>
<DialogTitle>Termin anfragen</DialogTitle>
<DialogDescription>
Frage einen privaten Termin (z.B. Kindergeburtstag) in den Räumlichkeiten der Kita an.
Die Anfrage muss von einem Admin bestätigt werden.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Titel</Label>
<Input id="title" name="title" required placeholder="z.B. Geburtstag von Mia" />
</div>
<div className="grid gap-2">
<Label htmlFor="type">Kategorie</Label>
<select
id="type"
name="type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
>
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
<option value={TerminType.ELTERNABEND}>Elternabend</option>
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
<option value={TerminType.SONSTIGES}>Sonstiges</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="startDate">Start</Label>
<Input id="startDate" name="startDate" type="datetime-local" required />
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Ende</Label>
<Input id="endDate" name="endDate" type="datetime-local" required />
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
<Label htmlFor="allDay" className="font-normal cursor-pointer">
Ganztägiger Termin
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Wird gesendet..." : "Anfrage stellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+235
View File
@@ -0,0 +1,235 @@
"use server";
import { revalidatePath } from "next/cache";
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /dashboard/kalender · Server Actions
// =====================================================================
const terminSchema = z.object({
title: z.string().min(2, "Titel muss mindestens 2 Zeichen lang sein"),
description: z.string().optional(),
type: z.nativeEnum(TerminType),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
allDay: z.boolean().default(false),
});
export async function createTerminRequest(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
try {
await prisma.termin.create({
data: {
kitaId: session.user.kitaId,
createdById: session.user.id,
title: data.title,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.PENDING,
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen der Terminanfrage:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function createTerminAdmin(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const parsed = terminSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const data = parsed.data;
try {
await prisma.termin.create({
data: {
kitaId: session.user.kitaId,
createdById: session.user.id,
title: data.title,
description: data.description,
type: data.type,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
allDay: data.allDay,
status: TerminStatus.CONFIRMED,
approvedById: session.user.id,
approvedAt: new Date(),
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Erstellen des Termins:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function approveTermin(terminId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
},
data: {
status: TerminStatus.CONFIRMED,
approvedById: session.user.id,
approvedAt: new Date(),
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Bestätigen des Termins:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function rejectTermin(terminId: string, reason?: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
},
data: {
status: TerminStatus.REJECTED,
rejectionReason: reason,
approvedById: session.user.id,
approvedAt: new Date(),
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Ablehnen des Termins:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
try {
await prisma.termin.update({
where: {
id: terminId,
kitaId: session.user.kitaId,
},
data: {
mitbringselListEnabled: enabled,
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
const mitbringselSchema = z.object({
terminId: z.string(),
content: z.string().min(1, "Eintrag darf nicht leer sein"),
});
export async function addMitbringsel(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = mitbringselSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { terminId, content } = parsed.data;
try {
// Verify termin is active and list is enabled
const termin = await prisma.termin.findUnique({
where: { id: terminId, kitaId: session.user.kitaId },
});
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
}
await prisma.mitbringselItem.create({
data: {
kitaId: session.user.kitaId,
terminId: terminId,
userId: session.user.id,
content: content,
},
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Hinzufügen des Eintrags:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
export async function deleteMitbringsel(itemId: string) {
const session = await requireKitaSession();
try {
const item = await prisma.mitbringselItem.findUnique({
where: { id: itemId, kitaId: session.user.kitaId },
});
if (!item) {
return { error: "Eintrag nicht gefunden." };
}
// Admins/Koords can delete any item. Users can only delete their own.
if (
session.user.role !== UserRole.ADMIN &&
session.user.role !== UserRole.KOORDINATOR &&
item.userId !== session.user.id
) {
return { error: "Keine Berechtigung diesen Eintrag zu löschen." };
}
await prisma.mitbringselItem.delete({
where: { id: itemId },
});
revalidatePath("/dashboard/kalender");
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Eintrags:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
}
+133
View File
@@ -0,0 +1,133 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { UserRole, TerminStatus } from "@prisma/client";
import { TerminList } from "./_components/termin-list";
import { PendingAnfragen } from "./_components/pending-anfragen";
import { TerminRequestModal } from "./_components/termin-request-modal";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { CalendarDays } from "lucide-react";
export default async function KalenderPage({
searchParams,
}: {
searchParams: { tab?: string };
}) {
const session = await requireKitaSession();
const isAdmin =
session.user.role === UserRole.ADMIN || session.user.role === UserRole.KOORDINATOR;
const currentTab = searchParams.tab || "übersicht";
// Fetch confirmed events
const confirmedTermine = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
include: {
mitbringselItems: {
include: {
user: { select: { firstName: true, lastName: true } },
},
},
},
orderBy: { startDate: "asc" },
});
// Fetch user's own pending events
const myPendingTermine = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
createdById: session.user.id,
},
orderBy: { startDate: "asc" },
});
// Combine for general view
const allUserTermine = [...confirmedTermine, ...myPendingTermine].sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime()
);
// If admin, fetch all pending events
let allPendingTermine: any[] = [];
if (isAdmin) {
allPendingTermine = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
},
include: {
createdBy: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: "desc" },
});
}
return (
<div className="flex h-full flex-col gap-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Terminkalender</h1>
<p className="text-muted-foreground">
Alle anstehenden Termine, Feste und Schließtage im Überblick.
</p>
</div>
<div className="flex gap-2">
<TerminRequestModal />
{isAdmin && <AdminTerminModal />}
</div>
</div>
{isAdmin ? (
<div className="flex flex-col gap-4">
<div className="flex gap-4 border-b">
<a
href="/dashboard/kalender?tab=übersicht"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary ${
currentTab === "übersicht"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Übersicht
</a>
<a
href="/dashboard/kalender?tab=anfragen"
className={`pb-2 text-sm font-medium transition-colors hover:text-primary flex items-center gap-2 ${
currentTab === "anfragen"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground"
}`}
>
Ausstehende Anfragen
{allPendingTermine.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{allPendingTermine.length}
</span>
)}
</a>
</div>
<div className="pt-2">
{currentTab === "übersicht" ? (
<TerminList
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
) : (
<PendingAnfragen termine={allPendingTermine} />
)}
</div>
</div>
) : (
<TerminList
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
)}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import { Baby, LogOut } from "lucide-react";
import { auth, signOut } from "@/auth";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
// =====================================================================
// Dashboard-Layout · Linke Sidebar
// ---------------------------------------------------------------------
// 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.
// =====================================================================
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await requireKitaSession();
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: { name: true },
});
return (
<div className="flex min-h-screen bg-muted/30">
{/* ── Sidebar ───────────────────────────────────────────────────── */}
<aside className="flex w-60 shrink-0 flex-col border-r bg-background">
{/* Logo / Kita-Name */}
<div className="flex h-16 items-center gap-2.5 border-b px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Baby className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none">
{kita.name}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">Kita-Planer</p>
</div>
</div>
{/* Navigation */}
<div className="flex-1 overflow-y-auto py-4">
<SidebarNav role={session.user.role as any} />
</div>
{/* Footer: User-Info + Abmelden */}
<div className="border-t p-3">
<Separator className="mb-3" />
<div className="mb-2 px-3">
<p className="truncate text-xs font-medium">
{session.user.name ?? session.user.email}
</p>
<p className="truncate text-xs text-muted-foreground">
{session.user.role}
</p>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
{/* ── Main Content ──────────────────────────────────────────────── */}
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use server";
import { revalidatePath } from "next/cache";
import { UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils";
// =====================================================================
// /dashboard/notdienst · Server Actions
// ---------------------------------------------------------------------
// Speichert die gebuchten Termine der Eltern im Zielmonat.
// Die Termine werden Round-Robin auf die Kinder aufgeteilt.
// =====================================================================
const payloadSchema = z.object({
childrenIds: z.array(z.string()).min(1),
dates: z.array(z.string().datetime()), // ISO strings
});
export async function saveNotdienstAvailabilities(payloadRaw: {
childrenIds: string[];
dates: string[];
}) {
const session = await requireRole([
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
]);
const parsed = payloadSchema.safeParse(payloadRaw);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
const { childrenIds, dates } = parsed.data;
// ── 1. Sperrfrist prüfen ───────────────────────────────────────────
const { targetYear, targetMonth, isLocked } = getTargetMonthData();
if (isLocked) {
return { error: "Die Planung für diesen Monat ist bereits abgeschlossen." };
}
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: { notdienstMinPerChildPerMonth: true },
});
const requiredTotal = childrenIds.length * kita.notdienstMinPerChildPerMonth;
if (dates.length < requiredTotal) {
return {
error: `Bitte wähle mindestens ${requiredTotal} Tage aus.`,
};
}
// Sicherstellen, dass alle Kinder zum angemeldeten User gehören und zur kitaId passen
const validChildren = await prisma.child.findMany({
where: {
id: { in: childrenIds },
kitaId,
parentLinks: { some: { userId: session.user.id } },
},
select: { id: true },
});
if (validChildren.length !== childrenIds.length) {
return { error: "Ungültige Kinder-Zuordnung erkannt." };
}
// Termine in Date-Objekte wandeln und sortieren
const parsedDates = dates
.map((d) => new Date(d))
.sort((a, b) => a.getTime() - b.getTime());
// Round-Robin Zuweisung
const inserts: {
kitaId: string;
childId: string;
userId: string;
date: Date;
}[] = [];
for (let i = 0; i < parsedDates.length; i++) {
const childId = validChildren[i % validChildren.length].id;
inserts.push({
kitaId,
childId,
userId: session.user.id,
date: parsedDates[i],
});
}
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
try {
await prisma.$transaction(async (tx) => {
// a) Alle *eigenen* bestehenden Einträge im Zielmonat für diese Kinder löschen
await tx.notdienstAvailability.deleteMany({
where: {
kitaId,
userId: session.user.id,
childId: { in: childrenIds },
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
});
// b) Neue Einträge setzen
if (inserts.length > 0) {
await tx.notdienstAvailability.createMany({
data: inserts,
skipDuplicates: true,
});
}
});
revalidatePath("/dashboard/notdienst");
return { success: true };
} catch (error) {
console.error("Fehler beim Speichern der Notdienste:", error);
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
}
}
@@ -0,0 +1,184 @@
"use client";
import { useState, useTransition } from "react";
import { Info, Loader2, CheckCircle2 } from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { saveNotdienstAvailabilities } from "./actions";
// =====================================================================
// NotdienstForm · Client Component
// ---------------------------------------------------------------------
// Erlaubt das Auswählen von N Tagen aus der Liste der Werktage des
// Zielmonats. Speichert als Array von Datum-Strings.
// =====================================================================
interface NotdienstFormProps {
targetYear: number;
targetMonth: number;
isLocked: boolean;
requiredDaysTotal: number;
initialSelectedDates: string[]; // ISO Strings
childrenIds: string[];
}
export function NotdienstForm({
targetYear,
targetMonth,
isLocked,
requiredDaysTotal,
initialSelectedDates,
childrenIds,
}: NotdienstFormProps) {
// Lokaler State für die Auswahl. Ein Datum liegt immer als ISO-String vor (z.B. "2026-06-01T00:00:00.000Z")
const [selectedDates, setSelectedDates] = useState<Set<string>>(
new Set(initialSelectedDates),
);
const [isPending, startTransition] = useTransition();
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
const selectedCount = selectedDates.size;
const isFulfilled = selectedCount >= requiredDaysTotal;
function toggleDate(dateStr: string) {
if (isLocked || isPending) return;
const newSet = new Set(selectedDates);
if (newSet.has(dateStr)) {
newSet.delete(dateStr);
} else {
newSet.add(dateStr);
}
setSelectedDates(newSet);
}
function handleSave() {
if (isLocked) return;
if (!isFulfilled) {
toast.error(
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
);
return;
}
startTransition(async () => {
const payload = {
dates: Array.from(selectedDates),
childrenIds,
};
const result = await saveNotdienstAvailabilities(payload);
if (result?.error) {
toast.error(result.error);
} else {
toast.success("Verfügbarkeiten erfolgreich gespeichert.");
}
});
}
return (
<div className="space-y-6">
{/* Fortschritt / Sperr-Hinweis */}
<div
className={cn(
"flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between",
isLocked ? "bg-destructive/5" : "bg-card",
)}
>
<div className="flex items-start gap-3">
{isLocked ? (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
) : isFulfilled ? (
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
) : (
<Info className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div>
<h3 className="font-medium">
{isLocked
? "Planung abgeschlossen"
: "Deine ausgewählten Termine"}
</h3>
<p className="text-sm text-muted-foreground">
{isLocked
? "Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht mehr möglich."
: `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`}
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={isLocked || isPending || !isFulfilled}
className="shrink-0"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isLocked ? "Gesperrt" : "Speichern"}
</Button>
</div>
{/* Datumsliste */}
<div className="rounded-lg border bg-card">
<div className="p-4 border-b bg-muted/30">
<p className="text-sm font-medium">Werktage im {format(new Date(targetYear, targetMonth - 1, 1), "MMMM", { locale: de })}</p>
</div>
<div className="divide-y">
{workingDays.map((day) => {
const dateStr = day.toISOString();
const isSelected = selectedDates.has(dateStr);
const isToday = day.getTime() === new Date().setHours(0, 0, 0, 0);
return (
<button
key={dateStr}
type="button"
disabled={isLocked || isPending}
onClick={() => toggleDate(dateStr)}
className={cn(
"flex w-full items-center justify-between px-4 py-3 text-left transition-colors",
!isLocked && !isPending && "hover:bg-muted/50",
isSelected && "bg-primary/5 hover:bg-primary/10",
(isLocked || isPending) && "cursor-not-allowed opacity-70",
)}
>
<div className="flex items-center gap-3">
{/* Custom Checkbox Indicator */}
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded border",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background",
)}
>
{isSelected && <CheckCircle2 className="h-3.5 w-3.5" />}
</div>
<div>
<span className={cn("font-medium", isSelected && "text-primary")}>
{format(day, "EEEE, dd. MMMM", { locale: de })}
</span>
{isToday && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
Heute
</span>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { UserRole } from "@prisma/client";
import { Info, Calendar } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData } from "@/lib/date-utils";
import { NotdienstForm } from "./notdienst-form";
export const metadata = { title: "Notdienst · Kita-Planer" };
export default async function NotdienstPage() {
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
// ebenfalls durch, falls sie selbst Kinder haben.
const session = await requireRole([
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return <div className="p-8">Kein Mandant zugeordnet.</div>;
}
// Kita-Einstellungen laden
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: { notdienstMinPerChildPerMonth: true },
});
// Nur eigene, aktive Kinder laden
const children = await prisma.child.findMany({
where: {
kitaId,
active: true,
parentLinks: { some: { userId: session.user.id } },
},
select: { id: true, firstName: true },
});
const targetData = getTargetMonthData();
const { targetYear, targetMonth, monthName, isLocked } = targetData;
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
// Bisherige Einträge für den Zielmonat laden
// Wir filtern bewusst nach den eigenen Kindern und dem User
const availabilities = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
userId: session.user.id,
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
select: { date: true },
});
const selectedDates = availabilities.map((a) => a.date.toISOString());
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" />
Notdienst planen
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Gib hier an, an welchen Tagen du im Notdienst unterstützen kannst.
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
{isLocked && (
<span className="text-xs font-medium text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
Eingabefrist abgelaufen
</span>
)}
</div>
</div>
{children.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
Deinem Account sind noch keine Kinder zugeordnet.
</div>
) : (
<NotdienstForm
targetYear={targetYear}
targetMonth={targetMonth}
isLocked={isLocked}
requiredDaysTotal={requiredDaysTotal}
initialSelectedDates={selectedDates}
childrenIds={children.map((c) => c.id)}
/>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use server";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
// =====================================================================
export async function generatePlanAction() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth } = getTargetMonthData();
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
});
if (existingPlan) {
return { error: "Ein Plan für diesen Monat existiert bereits." };
}
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
const availabilities = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: new Date(targetYear, targetMonth - 1, 1),
lt: new Date(targetYear, targetMonth, 1),
},
},
include: { child: true, user: true },
});
// 2. Werktage holen
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// 3. Algorithmus: Wir verteilen die Slots
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
const familyUsageCount: Record<string, number> = {};
const assignmentsToCreate: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
// Alle Verfügbarkeiten für diesen spezifischen Tag
const availForDay = availabilities.filter(
(a) => a.date.getTime() === day.getTime(),
);
if (availForDay.length === 0) {
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
continue;
}
// Finde die Familie, die am wenigsten oft dran war
// Zufall einbauen bei Gleichstand
const sorted = availForDay.sort((a, b) => {
const countA = familyUsageCount[a.userId] || 0;
const countB = familyUsageCount[b.userId] || 0;
if (countA === countB) {
return Math.random() - 0.5; // Zufallsmischung
}
return countA - countB;
});
const selected = sorted[0];
assignmentsToCreate.push({
childId: selected.childId,
date: day,
});
// Usage-Counter erhöhen
familyUsageCount[selected.userId] =
(familyUsageCount[selected.userId] || 0) + 1;
}
// 4. In der DB speichern
try {
await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.create({
data: {
kitaId,
year: targetYear,
month: targetMonth,
status: NotdienstPlanStatus.DRAFT,
createdById: session.user.id,
},
});
if (assignmentsToCreate.length > 0) {
await tx.notdienstAssignment.createMany({
data: assignmentsToCreate.map((a) => ({
kitaId,
planId: plan.id,
childId: a.childId,
date: a.date,
})),
});
}
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler bei der Generierung des Plans." };
}
}
export async function publishPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
await prisma.notdienstPlan.update({
where: { id: planId, kitaId },
data: {
status: NotdienstPlanStatus.PUBLISHED,
publishedAt: new Date(),
},
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
return { success: true };
}
export async function updateAssignmentAction(
planId: string,
date: string,
newChildId: string | null,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const parsedDate = new Date(date);
try {
await prisma.$transaction(async (tx) => {
// Prüfen, ob Plan noch DRAFT ist
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.");
}
// Altes Assignment für diesen Tag löschen
await tx.notdienstAssignment.deleteMany({
where: { planId, date: parsedDate },
});
// Neues anlegen, falls ausgewählt
if (newChildId) {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
});
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error: any) {
return { error: error.message || "Update fehlgeschlagen." };
}
}
+93
View File
@@ -0,0 +1,93 @@
import { UserRole } from "@prisma/client";
import { Settings2 } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
export default async function PlanungsZentralePage() {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const { targetYear, targetMonth, monthName } = getTargetMonthData();
// Aktuellen Plan suchen
const plan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
include: {
assignments: {
include: { child: { include: { parentLinks: { include: { user: true } } } } },
},
},
});
// Alle Werktage
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
include: {
parentLinks: { include: { user: true } },
},
orderBy: { lastName: "asc" },
});
const allChildren = allChildrenRaw.map((c) => {
// Einfachheit halber nehmen wir den ersten verknüpften Elternteil zur Anzeige
const parent = c.parentLinks[0]?.user;
const parentName = parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt";
return {
id: c.id,
name: `${c.firstName} ${c.lastName}`,
parentName,
};
});
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
);
return {
date: day.toISOString(),
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
};
});
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-8">
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6 text-primary" />
Notdienst-Planung
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Koordiniere und generiere den Notdienst-Plan.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
Zielmonat: {monthName}
</span>
</div>
</div>
<PlanView
planId={plan?.id}
status={plan?.status}
days={daysWithAssignments}
allChildren={allChildren}
/>
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
generatePlanAction,
publishPlanAction,
updateAssignmentAction,
} from "./actions";
interface PlanViewProps {
planId?: string;
status?: NotdienstPlanStatus;
days: {
date: string; // ISO
assignmentId: string | null;
childId: string | null;
}[];
allChildren: {
id: string;
name: string;
parentName: string;
}[];
}
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
const [isPending, startTransition] = useTransition();
const handleGenerate = () => {
startTransition(async () => {
const result = await generatePlanAction();
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan erfolgreich generiert.");
}
});
};
const handlePublish = () => {
if (!planId) return;
startTransition(async () => {
const result = await publishPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan veröffentlicht! Er ist nun fix.");
}
});
};
const handleAssignmentChange = (date: string, newChildId: string) => {
if (!planId || status !== "DRAFT") return;
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
const result = await updateAssignmentAction(planId, date, valueToSet);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Zuteilung aktualisiert.");
}
});
};
if (!planId) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Wand2 className="h-6 w-6" />
</div>
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern
automatisch.
</p>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Plan automatisch generieren
</Button>
</div>
);
}
const isDraft = status === "DRAFT";
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<Badge variant={isDraft ? "warning" : "success"}>
{isDraft ? "Entwurf" : "Veröffentlicht"}
</Badge>
<span className="text-sm text-muted-foreground">
{isDraft
? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."}
</span>
</div>
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckSquare className="mr-2 h-4 w-4" />
Plan veröffentlichen
</Button>
)}
</div>
<div className="rounded-lg border bg-card">
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y">
{days.map((day) => {
const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId;
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10"
>
<div className="font-medium">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={day.childId || "empty"}
disabled={isPending}
onChange={(e) =>
handleAssignmentChange(day.date, e.target.value)
}
>
<option value="empty" className="text-destructive">
-- Unbesetzt --
</option>
{allChildren.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option>
))}
</select>
) : (
<div>
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt!
</span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import Link from "next/link";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle } from "lucide-react";
import { UserRole, NotdienstPlanStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { AlertButton } from "./alert-button";
export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: {
name: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
},
});
// Schnelle Statistiken für die Übersicht
const [familyCount, childCount] = await Promise.all([
prisma.user.count({
where: { kitaId: session.user.kitaId, role: UserRole.ELTERN },
}),
prisma.child.count({
where: { kitaId: session.user.kitaId },
}),
]);
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
return (
<div className="px-8 py-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Hier ist die Übersicht für <strong>{kita.name}</strong>.
</p>
</div>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
</div>
)}
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Elternteile"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
/>
<StatCard
label="Kinder"
value={childCount}
description="in der Einrichtung"
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />}
/>
</div>
{/* Schnell-Aktionen für Admins */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schnellstart</CardTitle>
<CardDescription>
Die nächsten Schritte, um deine Kita einzurichten.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button asChild size="sm">
<Link href="/dashboard/families">
<Users className="mr-2 h-4 w-4" />
Familien verwalten
</Link>
</Button>
{kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline">
<Link href="/dashboard/termine">
<CalendarCheck2 className="mr-2 h-4 w-4" />
Termine (bald verfügbar)
</Link>
</Button>
)}
</CardContent>
</Card>
)}
</div>
);
}
function StatCard({
label,
value,
description,
icon,
}: {
label: string;
value: number;
description: string;
icon: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Wir suchen das Assignment für heute, aber nur wenn der Plan PUBLISHED ist
const assignment = await prisma.notdienstAssignment.findFirst({
where: {
kitaId,
date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED },
},
include: {
child: { include: { parentLinks: { include: { user: true } } } },
alerts: true, // Um zu sehen, ob schon ein Alarm ausgelöst wurde
},
});
if (!assignment) return null;
const parent = assignment.child.parentLinks[0]?.user;
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-primary" />
Heutiger Notdienst
</CardTitle>
<CardDescription>
Laut dem veröffentlichten Plan ist heute folgende Familie eingeteilt:
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-base">
{parent ? `${parent.firstName} ${parent.lastName}` : "Unbekannt"}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
</p>
</div>
<div className="flex items-center gap-3">
{existingAlert ? (
<Badge
variant={
existingAlert.status === "CONFIRMED"
? "success"
: existingAlert.status === "PENDING"
? "warning"
: "destructive"
}
className="px-3 py-1 text-sm"
>
{existingAlert.status === "CONFIRMED"
? "Einsatz bestätigt"
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton
assignmentId={assignment.id}
parentUserId={parent?.id || ""}
/>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState, useTransition } from "react";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { signOut } from "next-auth/react";
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 { deleteMyAccount } from "../actions";
export function DeleteAccountDialog() {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [isPending, startTransition] = useTransition();
const isConfirmed = confirmText === "LÖSCHEN";
const handleDelete = () => {
if (!isConfirmed) return;
startTransition(async () => {
const res = await deleteMyAccount();
if (res.error) {
toast.error(res.error);
} else {
toast.success("Account wurde gelöscht. Du wirst abgemeldet...");
// Sign out client-side and redirect to landing page
await signOut({ callbackUrl: "/" });
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto">
<AlertTriangle className="h-4 w-4 mr-2" />
Account & Daten endgültig löschen
</Button>
</DialogTrigger>
<DialogContent className="border-destructive/30">
<DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Gefahrenzone: Account löschen
</DialogTitle>
<DialogDescription className="text-base pt-2">
Diese Aktion kann <strong className="text-foreground">nicht</strong> rückgängig gemacht werden.
Alle deine personenbezogenen Daten, Verknüpfungen zu deinen Kindern, gebuchten Notdienste
und Mitbringsel-Einträge werden DSGVO-konform sofort und unwiderruflich aus der Datenbank gelöscht.
</DialogDescription>
</DialogHeader>
<div className="bg-destructive/10 text-destructive-foreground p-3 text-sm rounded-md my-2 border border-destructive/20">
Bitte tippe das Wort <strong>LÖSCHEN</strong> in das Feld unten ein, um den Vorgang zu bestätigen.
</div>
<div className="grid gap-2 py-4">
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="LÖSCHEN"
className="border-destructive/50 focus-visible:ring-destructive"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isConfirmed || isPending}
>
{isPending ? "Wird gelöscht..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { signOut } from "@/auth"; // Note: this is NextAuth v5 server side signOut if needed, but we do client side
export async function deleteMyAccount() {
const session = await requireKitaSession();
try {
// Da wir onDelete: Cascade überall konfiguriert haben,
// löscht dieser eine Befehl den User, seine ChildParent-Links,
// seine MitbringselItems, seine NotdienstAvailabilities etc.
await prisma.user.delete({
where: {
id: session.user.id,
},
});
// The client component will trigger the actual NextAuth client signOut,
// but returning success indicates the DB deletion was successful.
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Accounts:", error);
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
}
}
+142
View File
@@ -0,0 +1,142 @@
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { UserCircle, Mail, Baby, Shield, CalendarHeart } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DeleteAccountDialog } from "./_components/delete-account-dialog";
export const metadata = { title: "Mein Profil · Kita-Planer" };
export default async function ProfilPage() {
const session = await requireKitaSession();
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
include: {
childLinks: {
include: { child: true },
},
dutyAssignments: {
include: { duty: true },
},
},
});
return (
<div className="flex h-full flex-col gap-6 p-6 max-w-4xl mx-auto w-full">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1>
<p className="text-muted-foreground">
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCircle className="h-5 w-5" />
Persönliche Daten
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div>
<div className="text-sm text-muted-foreground">Name</div>
<div className="font-medium text-lg">
{user.firstName} {user.lastName}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<Mail className="h-3.5 w-3.5" /> E-Mail
</div>
<div className="font-medium">{user.email}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Rolle im Verein</div>
<Badge variant={user.role === "ELTERN" ? "secondary" : "default"} className="mt-1">
{user.role}
</Badge>
</div>
<div className="pt-2 border-t mt-2">
<div className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarHeart className="h-3.5 w-3.5" /> Mitglied seit
</div>
<div className="text-sm">
{format(user.createdAt, "PPP", { locale: de })}
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
{/* Kinder */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Baby className="h-5 w-5" />
Meine Kinder
</CardTitle>
</CardHeader>
<CardContent>
{user.childLinks.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
) : (
<div className="flex flex-col gap-2">
{user.childLinks.map((link) => (
<div key={link.child.id} className="flex items-center justify-between p-2 rounded-md bg-muted/50">
<span className="font-medium">
{link.child.firstName} {link.child.lastName}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Ämter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Meine Ämter & Dienste
</CardTitle>
</CardHeader>
<CardContent>
{user.dutyAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground">Du hast aktuell keine festen Ämter übernommen.</p>
) : (
<div className="flex flex-wrap gap-2">
{user.dutyAssignments.map((assignment) => (
<Badge key={assignment.id} variant="default" className="text-sm py-1 px-3">
{assignment.duty.name}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Danger Zone */}
<Card className="border-destructive/20 mt-8">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Hier kannst du dein Profil und alle zugehörigen Daten DSGVO-konform löschen.
</CardDescription>
</CardHeader>
<CardContent>
<DeleteAccountDialog />
</CardContent>
</Card>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { ShieldX } from "lucide-react";
import { Button } from "@/components/ui/button";
export const metadata = { title: "Zugriff verweigert · Kita-Planer" };
export default function ForbiddenPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 text-center">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<ShieldX className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Zugriff verweigert
</h1>
<p className="mt-3 max-w-sm text-sm text-muted-foreground">
Du hast keine Berechtigung, diese Seite aufzurufen. Falls du glaubst,
dass dies ein Fehler ist, wende dich an deinen Kita-Administrator.
</p>
<div className="mt-8 flex gap-3">
<Button asChild>
<Link href="/dashboard">Zum Dashboard</Link>
</Button>
<Button asChild variant="outline">
<Link href="/login">Anmelden</Link>
</Button>
</div>
</div>
);
}
+71 -11
View File
@@ -1,26 +1,86 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --background: oklch(0.145 0 0);
--foreground: #ededed; --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 0.1);
--input: oklch(1 0 0 / 0.15);
--ring: oklch(0.556 0 0);
} }
} }
body { @layer base {
background: var(--background); * {
color: var(--foreground); border-color: var(--border);
font-family: Arial, Helvetica, sans-serif; }
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans), system-ui, sans-serif;
}
} }
+135
View File
@@ -0,0 +1,135 @@
"use server";
import { hash } from "bcryptjs";
import { z } from "zod";
import { signIn } from "@/auth";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /invite/[token] · Server Action
// ---------------------------------------------------------------------
// acceptInviteAction:
// 1. Token erneut gegen DB prüfen (kein Trust auf Client-State)
// 2. Passwort hashen + User-Daten setzen (DSGVO-Timestamps)
// 3. Token löschen (kein Replay möglich)
// 4. Direkt einloggen + Redirect auf /dashboard
// =====================================================================
const inviteSchema = z
.object({
token: z.string().min(1),
password: z.string().min(8, "Mindestens 8 Zeichen.").max(72, "Maximal 72 Zeichen."),
confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."),
acceptPrivacyPolicy: z.literal("on", {
error: "Bitte die Datenschutzerklärung akzeptieren.",
}),
directoryOptIn: z.enum(["on", "off"]).optional(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwörter stimmen nicht überein.",
path: ["confirmPassword"],
});
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type InviteState = {
errors?: {
password?: string[];
confirmPassword?: string[];
acceptPrivacyPolicy?: string[];
directoryOptIn?: string[];
_form?: string[];
};
};
export async function acceptInviteAction(
_prev: InviteState,
formData: FormData,
): Promise<InviteState> {
const parsed = inviteSchema.safeParse({
token: formData.get("token"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy"),
directoryOptIn: formData.get("directoryOptIn") ?? "off",
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const { token, password, directoryOptIn } = parsed.data;
const now = new Date();
// ── 1. Token erneut DB-seitig validieren ──────────────────────────
const verificationToken = await prisma.verificationToken.findUnique({
where: { token },
});
if (!verificationToken) {
return { errors: { _form: ["Einladungslink ist ungültig."] } };
}
if (verificationToken.expires < now) {
return {
errors: {
_form: [
"Dieser Einladungslink ist abgelaufen. Bitte wende dich an deinen Administrator.",
],
},
};
}
const userId = verificationToken.identifier;
// ── 2. User validieren (existiert und hat noch kein Passwort) ──────
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, passwordHash: true },
});
if (!user) {
return { errors: { _form: ["Benutzer nicht gefunden."] } };
}
if (user.passwordHash !== "") {
// Invite wurde bereits eingelöst
return {
errors: {
_form: [
"Dieser Einladungslink wurde bereits verwendet. Bitte melde dich an.",
],
},
};
}
// ── 3. Passwort hashen ─────────────────────────────────────────────
const passwordHash = await hash(password, 12);
// ── 4. User updaten + Token löschen (atomar) ──────────────────────
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
passwordHash,
privacyPolicyAcceptedAt: now,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
directoryOptInAt: directoryOptIn === "on" ? now : null,
emailVerifiedAt: now,
lastLoginAt: now,
},
}),
prisma.verificationToken.delete({
where: { token },
}),
]);
// ── 5. Direkt einloggen → wirft NEXT_REDIRECT ─────────────────────
await signIn("credentials", {
email: user.email,
password,
redirectTo: "/dashboard",
});
// Unreachable signIn redirected.
return {};
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { acceptInviteAction, type InviteState } from "./actions";
const initialState: InviteState = {};
// =====================================================================
// InviteForm · Client Component
// ---------------------------------------------------------------------
// Empfängt den Token als Prop (aus der Server Component) und übergibt
// ihn als Hidden-Input an die Server Action — der Client "kennt" den
// Token nur zum Weiterleiten, validiert ihn nicht selbst.
// =====================================================================
export function InviteForm({
token,
userName,
}: {
token: string;
userName: string;
}) {
const [state, formAction, pending] = useActionState(
acceptInviteAction,
initialState,
);
return (
<form action={formAction} className="space-y-5">
{/* Token als verstecktes Feld */}
<input type="hidden" name="token" value={token} />
<div className="space-y-1.5">
<p className="text-sm text-muted-foreground">
Du wurdest eingeladen als: <strong>{userName}</strong>
</p>
</div>
{/* Passwort */}
<div className="space-y-1.5">
<Label htmlFor="password">Passwort wählen</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.password}
/>
{state.errors?.password?.[0] ? (
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
) : (
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p>
)}
</div>
{/* Passwort bestätigen */}
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
aria-invalid={!!state.errors?.confirmPassword}
/>
{state.errors?.confirmPassword?.[0] && (
<p className="text-xs text-destructive">
{state.errors.confirmPassword[0]}
</p>
)}
</div>
{/* DSGVO-Pflicht */}
<div className="rounded-md border p-4 space-y-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Einwilligungen (Pflicht)
</p>
<div className="flex items-start gap-3">
<Checkbox
id="acceptPrivacyPolicy"
name="acceptPrivacyPolicy"
required
aria-invalid={!!state.errors?.acceptPrivacyPolicy}
/>
<div className="space-y-0.5">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "}
<a
href="/datenschutz"
className="underline"
target="_blank"
rel="noreferrer"
>
Datenschutzerklärung
</a>{" "}
gelesen und akzeptiere sie. <span className="text-destructive">*</span>
</Label>
{state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">
{state.errors.acceptPrivacyPolicy[0]}
</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="directoryOptIn"
name="directoryOptIn"
aria-invalid={!!state.errors?.directoryOptIn}
/>
<div className="space-y-0.5">
<Label htmlFor="directoryOptIn" className="text-sm font-normal">
Ich stimme zu, dass meine Kontaktdaten im internen Kita-Adressbuch
für andere Eltern sichtbar sind (Opt-In, jederzeit widerrufbar).
</Label>
</div>
</div>
</div>
{/* Globaler Fehler */}
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending} size="lg">
{pending ? "Wird eingerichtet…" : "Konto aktivieren & anmelden"}
</Button>
</form>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { Baby } from "lucide-react";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { InviteForm } from "./invite-form";
export const metadata = { title: "Konto aktivieren · Kita-Planer" };
// =====================================================================
// /invite/[token] · Öffentliche Seite (kein Auth erforderlich)
// ---------------------------------------------------------------------
// 1. Token gegen DB prüfen (existiert + nicht abgelaufen)
// 2. User laden (Name für personalisierte Begrüßung)
// 3. Invite bereits eingelöst → Hinweis
// 4. Gültiger Token → InviteForm
// =====================================================================
export default async function InvitePage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
// Token in DB nachschlagen
const verificationToken = await prisma.verificationToken.findUnique({
where: { token },
});
// Unbekannter Token → 404
if (!verificationToken) {
notFound();
}
const isExpired = verificationToken.expires < new Date();
// User über identifier (= userId) laden
const user = await prisma.user.findUnique({
where: { id: verificationToken.identifier },
select: {
firstName: true,
lastName: true,
email: true,
passwordHash: true,
kita: { select: { name: true } },
},
});
if (!user) notFound();
const userName = `${user.firstName} ${user.lastName}`;
const alreadyAccepted = user.passwordHash !== "";
return (
<div className="flex min-h-screen flex-col bg-muted/30">
{/* Mini-Header */}
<header className="border-b bg-background">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-2.5 px-6">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Baby className="h-4 w-4" />
</div>
<span className="text-sm font-semibold">Kita-Planer</span>
{user.kita && (
<>
<span className="text-sm text-muted-foreground">·</span>
<span className="text-sm text-muted-foreground">
{user.kita.name}
</span>
</>
)}
</div>
</header>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2">
{isExpired ? (
<>
<CardTitle>Einladungslink abgelaufen</CardTitle>
<CardDescription>
Dieser Link ist nicht mehr gültig. Bitte wende dich an deinen
Kita-Administrator, um einen neuen Link zu erhalten.
</CardDescription>
</>
) : alreadyAccepted ? (
<>
<CardTitle>Konto bereits aktiviert</CardTitle>
<CardDescription>
Du hast diesen Einladungslink bereits verwendet. Melde dich
einfach mit deiner E-Mail-Adresse an.
</CardDescription>
</>
) : (
<>
<CardTitle>Willkommen, {user.firstName}!</CardTitle>
<CardDescription>
Du wurdest zur Kita <strong>{user.kita?.name ?? "—"}</strong>{" "}
eingeladen. Setze dein Passwort, um dein Konto zu aktivieren.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
{isExpired || alreadyAccepted ? (
<div className="text-center">
<Link
href="/login"
className="text-sm font-medium underline underline-offset-4"
>
Zur Anmeldung
</Link>
</div>
) : (
<InviteForm token={token} userName={userName} />
)}
</CardContent>
</Card>
</main>
</div>
);
}
+8 -4
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Kita-Planer",
description: "Generated by create next app", description: "Der digitale Kita-Planer für Elterninitiativen",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,10 +25,13 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
{children}
<Toaster />
</body>
</html> </html>
); );
} }
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { AuthError } from "next-auth";
import { z } from "zod";
import { signIn } from "@/auth";
// =====================================================================
// /login · Server Action
// ---------------------------------------------------------------------
// Wir kapseln `signIn("credentials", …)` damit:
// • das Formular `useActionState` mit typisiertem Fehler-State nutzen kann
// • der Redirect-Trigger (NEXT_REDIRECT) sauber durchgereicht wird
// • generische Fehler ("Login fehlgeschlagen") weder Username-Existenz
// noch Lockout-Status leaken (kein User-Enumeration).
// =====================================================================
const loginSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
password: z.string().min(1, "Passwort fehlt."),
});
export type LoginState = {
errors?: {
email?: string[];
password?: string[];
_form?: string[];
};
};
export async function loginAction(
_prev: LoginState,
formData: FormData,
): Promise<LoginState> {
const parsed = loginSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const { email, password } = parsed.data;
try {
await signIn("credentials", {
email,
password,
// Auth-Utils entscheiden im Folge-Render, ob /onboarding oder /dashboard.
// Wir landen aus Sicherheitsgründen erstmal generisch auf der Wurzel.
// Der Proxy (proxy.ts) übernimmt das Routing:
// → /onboarding wenn kein kitaId, → /dashboard wenn vorhanden.
redirectTo: "/",
});
} catch (err) {
// signIn wirft NEXT_REDIRECT bei Erfolg → das müssen wir bewusst
// weiterwerfen, sonst wird der Redirect verschluckt.
if (err instanceof AuthError) {
return {
errors: { _form: ["E-Mail oder Passwort ist nicht korrekt."] },
};
}
throw err;
}
return {};
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = {};
export function LoginForm() {
const [state, formAction, pending] = useActionState(loginAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
aria-invalid={!!state.errors?.email}
/>
{state.errors?.email?.[0] && (
<p className="text-xs text-destructive">{state.errors.email[0]}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
aria-invalid={!!state.errors?.password}
/>
{state.errors?.password?.[0] && (
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
)}
</div>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Anmelden…" : "Anmelden"}
</Button>
</form>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LoginForm } from "./login-form";
export const metadata = { title: "Anmelden · Kita-Planer" };
export default async function LoginPage() {
const session = await auth();
if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
}
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">
<CardHeader className="space-y-2">
<CardTitle>Anmelden</CardTitle>
<CardDescription>Willkommen zurück.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<LoginForm />
<p className="text-center text-sm text-muted-foreground">
Noch keinen Account?{" "}
<Link href="/register" className="font-medium text-foreground underline">
Kita registrieren
</Link>
</p>
</CardContent>
</Card>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use server";
import { redirect } from "next/navigation";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /onboarding · Server Action
// ---------------------------------------------------------------------
// Erstellt die Kita (Tenant) und verknüpft den eingeloggten Gründer in
// einer einzigen Datenbank-Transaktion. Damit ist garantiert: entweder
// existiert hinterher beides (Kita + verknüpfter Admin) oder gar nichts
// — kein "halb-eingerichteter" Zwischenzustand.
// =====================================================================
const onboardingSchema = z.object({
kitaName: z
.string()
.min(2, "Mindestens 2 Zeichen.")
.max(120, "Maximal 120 Zeichen.")
.trim(),
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
notdienstMinPerChildPerMonth: z.coerce
.number()
.int("Bitte ganze Zahl.")
.min(0, "Nicht negativ.")
.max(31, "Maximal 31 Tage pro Monat."),
});
function checkboxToBool(value: unknown): boolean {
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
return value === "on" || value === "true" || value === true;
}
export type OnboardingState = {
errors?: {
kitaName?: string[];
notdienstMinPerChildPerMonth?: string[];
_form?: string[];
};
};
export async function completeOnboardingAction(
_prev: OnboardingState,
formData: FormData,
): Promise<OnboardingState> {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
if (session.user.kitaId) {
// Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten
// (z.B. durch zurück-navigieren oder Replay des Formulars).
redirect("/dashboard");
}
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const data = parsed.data;
try {
await prisma.$transaction(async (tx) => {
const kita = await tx.kita.create({
data: {
name: data.kitaName,
slug: await generateUniqueSlug(tx, data.kitaName),
notdienstModuleEnabled: data.notdienstModuleEnabled,
terminModuleEnabled: data.terminModuleEnabled,
adressbuchModuleEnabled: data.adressbuchModuleEnabled,
notdienstMinPerChildPerMonth: data.notdienstMinPerChildPerMonth,
},
});
// Defense-in-Depth: erlaube den Update nur, wenn der User aktuell
// tatsächlich noch keine Kita hat (verhindert Race-Conditions bei
// parallelen Onboarding-Submits).
const updated = await tx.user.updateMany({
where: { id: session.user.id, kitaId: null },
data: {
kitaId: kita.id,
role: UserRole.ADMIN,
},
});
if (updated.count === 0) {
throw new Error("USER_ALREADY_HAS_KITA");
}
});
} catch (err) {
if (err instanceof Error && err.message === "USER_ALREADY_HAS_KITA") {
redirect("/dashboard");
}
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return {
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
};
}
throw err;
}
// Frischer JWT wird beim nächsten Request automatisch neu befüllt
// (siehe jwt-Callback in auth.ts → re-validates from DB).
redirect("/dashboard");
}
// ---------------------------------------------------------------------
// Helfer
// ---------------------------------------------------------------------
async function generateUniqueSlug(
tx: Prisma.TransactionClient,
name: string,
): Promise<string> {
const base = slugify(name) || "kita";
let candidate = base;
let attempt = 0;
// In der Praxis genügen 1-2 Iterationen; max. 50 als Safety-Net.
while (attempt < 50) {
const exists = await tx.kita.findUnique({ where: { slug: candidate } });
if (!exists) return candidate;
attempt += 1;
candidate = `${base}-${attempt + 1}`;
}
return `${base}-${Date.now()}`;
}
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/ß/g, "ss")
.normalize("NFKD")
// strippt kombinierende Diakritika (ä → a, ö → o, é → e …)
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 60);
}
+72
View File
@@ -0,0 +1,72 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const metadata = { title: "Datenschutz-Einwilligung · Kita-Planer" };
// Diese Seite wird nur in einem sehr seltenen Edge-Case erreicht:
// Ein User mit kitaId hat noch kein `privacyPolicyAcceptedAt` gesetzt
// (z.B. über einen alten Seed oder manuellen DB-Eintrag).
// Im normalen Registrierungs-Flow wird die Einwilligung bereits
// in registerAction() zum Zeitpunkt der Kontoerstellung erfasst.
export default async function ConsentPage() {
const session = await requireSession();
// SUPERADMIN benötigt keinen Consent-Flow — hat keinen Kita-Kontext.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
// User ohne kitaId gehört in den Onboarding-Wizard.
if (!session.user.kitaId) {
redirect("/onboarding");
}
// Prüfen, ob der Consent wirklich fehlt (DB-seitiger Check).
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { privacyPolicyAcceptedAt: true },
});
// Consent bereits vorhanden → weiter ins Dashboard.
if (user?.privacyPolicyAcceptedAt) {
redirect("/dashboard");
}
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">
<CardHeader className="space-y-2">
<CardTitle>Datenschutz-Einwilligung erforderlich</CardTitle>
<CardDescription>
Dein Konto wurde ohne Datenschutz-Einwilligung angelegt. Bitte
wende dich an deinen Kita-Administrator oder den Support, um deinen
Account zu reaktivieren.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<p>
Falls du glaubst, dass dies ein Fehler ist, kontaktiere bitte{" "}
<a
href="mailto:support@kita-planer.de"
className="font-medium text-foreground underline"
>
support@kita-planer.de
</a>
.
</p>
</CardContent>
</Card>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions";
const initialState: OnboardingState = {};
export function OnboardingForm() {
const [state, formAction, pending] = useActionState(
completeOnboardingAction,
initialState,
);
return (
<form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 1 · Grunddaten
</legend>
<div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input
id="kitaName"
name="kitaName"
required
placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName}
/>
{state.errors?.kitaName?.[0] && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p>
)}
</div>
</fieldset>
{/* ----- Schritt 2: Module ----- */}
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 2 · Module aktivieren
</legend>
<ModuleCheckbox
name="notdienstModuleEnabled"
label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked
/>
<ModuleCheckbox
name="terminModuleEnabled"
label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked
/>
<ModuleCheckbox
name="adressbuchModuleEnabled"
label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked
/>
</fieldset>
{/* ----- Schritt 3: Notdienst-Regeln ----- */}
<fieldset className="space-y-4">
<legend className="text-sm font-semibold text-muted-foreground">
Schritt 3 · Notdienst-Regel
</legend>
<div className="space-y-1.5">
<Label htmlFor="notdienstMinPerChildPerMonth">
Mindest-Verfügbarkeiten pro Kind und Monat
</Label>
<Input
id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth"
type="number"
min={0}
max={31}
defaultValue={2}
required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth}
/>
<p className="text-xs text-muted-foreground">
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
Diesen Wert kannst du später jederzeit ändern.
</p>
{state.errors?.notdienstMinPerChildPerMonth?.[0] && (
<p className="text-xs text-destructive">
{state.errors.notdienstMinPerChildPerMonth[0]}
</p>
)}
</div>
</fieldset>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending} size="lg">
{pending ? "Wird eingerichtet…" : "Kita einrichten & loslegen"}
</Button>
</form>
);
}
function ModuleCheckbox({
name,
label,
description,
defaultChecked,
}: {
name: string;
label: string;
description: string;
defaultChecked?: boolean;
}) {
return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40">
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" />
<div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
</label>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireSession } from "@/lib/auth-utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { OnboardingForm } from "./onboarding-form";
export const metadata = { title: "Kita einrichten · Kita-Planer" };
export default async function OnboardingPage() {
const session = await requireSession();
// Guard: User MIT bereits zugeordneter Kita gehört nicht hierhin.
if (session.user.kitaId) {
redirect("/dashboard");
}
// Superadmins haben grundsätzlich keine Kita; sie nutzen /admin.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl">
<CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle>
<CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen.
</CardDescription>
</CardHeader>
<CardContent>
<OnboardingForm />
</CardContent>
</Card>
</div>
);
}
+94 -55
View File
@@ -1,65 +1,104 @@
import Image from "next/image"; import Link from "next/link";
import { redirect } from "next/navigation";
import { CalendarCheck2, ShieldCheck, Users } from "lucide-react";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
// Eingeloggte User von der Landingpage direkt weiterleiten — die Routing-
// Logik selbst (Onboarding vs. Dashboard) übernimmt `requireKitaSession`.
export default async function LandingPage() {
const session = await auth();
if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
}
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="flex min-h-screen flex-col">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <header className="border-b">
<Image <div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6">
className="dark:invert" <span className="text-lg font-semibold">Kita-Planer</span>
src="/next.svg" <nav className="flex items-center gap-3">
alt="Next.js logo" <Button asChild variant="ghost">
width={100} <Link href="/login">Anmelden</Link>
height={20} </Button>
priority <Button asChild>
/> <Link href="/register">Kita registrieren</Link>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> </Button>
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> </nav>
To get started, edit the page.tsx file. </div>
</h1> </header>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "} <main className="flex-1">
<a <section className="mx-auto w-full max-w-6xl px-6 py-24">
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <div className="max-w-3xl">
className="font-medium text-zinc-950 dark:text-zinc-50" <p className="mb-4 inline-block rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
> Für Elterninitiativen & Elternvereine
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p> </p>
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
Der digitale Kita-Planer für Elterninitiativen.
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Notdienst-Planung, Terminkalender und Stammdaten endlich
an einem Ort. Schluss mit Excel-Tabellen, WhatsApp-Listen und
vergessenen Geburtstagen.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button asChild size="lg">
<Link href="/register">Kita kostenlos registrieren</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/login">Bereits Mitglied? Anmelden</Link>
</Button>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> </div>
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <div className="mt-20 grid gap-8 sm:grid-cols-3">
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <FeatureCard
target="_blank" icon={<ShieldCheck className="h-6 w-6" />}
rel="noopener noreferrer" title="Notdienst-Planung"
> description="Verfügbarkeiten erfassen, fairen Plan automatisch generieren, Eltern bei Krankheitsausfall sofort alarmieren."
<Image />
className="dark:invert" <FeatureCard
src="/vercel.svg" icon={<CalendarCheck2 className="h-6 w-6" />}
alt="Vercel logomark" title="Terminkalender"
width={16} description="Kita-Feste, Schließtage und private Anfragen mit Mitbringsel-Listen — übersichtlich für alle Eltern."
height={16} />
<FeatureCard
icon={<Users className="h-6 w-6" />}
title="Eltern-Adressbuch"
description="Spielverabredungen leichter machen — auf Opt-In-Basis und DSGVO-konform."
/> />
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</section>
</main> </main>
<footer className="border-t">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-6 text-sm text-muted-foreground">
<span>© {new Date().getFullYear()} Kita-Planer</span>
<span>Made with für Elternvereine</span>
</div>
</footer>
</div>
);
}
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="rounded-lg border bg-card p-6">
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-md bg-secondary text-secondary-foreground">
{icon}
</div>
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div> </div>
); );
} }
+97
View File
@@ -0,0 +1,97 @@
"use server";
import { hash } from "bcryptjs";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { signIn } from "@/auth";
import { prisma } from "@/lib/prisma";
// =====================================================================
// /register · Server Action
// ---------------------------------------------------------------------
// Legt einen "heimatlosen" Admin-Account an (kitaId === null) und loggt
// ihn direkt ein. Die Tenant-Erstellung folgt im Onboarding-Wizard.
// =====================================================================
const registerSchema = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(),
// bcrypt akzeptiert max. 72 Bytes — wir capen davor zur Sicherheit
password: z
.string()
.min(8, "Mindestens 8 Zeichen.")
.max(72, "Maximal 72 Zeichen."),
firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(),
// HTML-Checkboxen senden "on" wenn aktiviert, sonst nichts
acceptPrivacyPolicy: z.literal("on", {
error: "Bitte Datenschutzerklärung akzeptieren.",
}),
});
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type RegisterState = {
errors?: {
email?: string[];
password?: string[];
firstName?: string[];
lastName?: string[];
acceptPrivacyPolicy?: string[];
_form?: string[];
};
};
export async function registerAction(
_prevState: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const parsed = registerSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const { email, password, firstName, lastName } = parsed.data;
const passwordHash = await hash(password, 12);
const now = new Date();
try {
await prisma.user.create({
data: {
email,
passwordHash,
firstName,
lastName,
// Gründer wird "heimatloser" Admin — beim Onboarding-Abschluss
// wird die Rolle bestätigt und der User mit einer Kita verknüpft.
role: UserRole.ADMIN,
kitaId: null,
privacyPolicyAcceptedAt: now,
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
emailVerifiedAt: now,
},
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return {
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
};
}
throw err;
}
// Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen.
await signIn("credentials", {
email,
password,
redirectTo: "/onboarding",
});
// Unreachable signIn redirected.
return {};
}
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { RegisterForm } from "./register-form";
export const metadata = { title: "Registrieren · Kita-Planer" };
export default async function RegisterPage() {
const session = await auth();
if (session?.user) {
redirect(session.user.kitaId ? "/dashboard" : "/onboarding");
}
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">
<CardHeader className="space-y-2">
<CardTitle>Kita kostenlos registrieren</CardTitle>
<CardDescription>
Lege deinen Account an. Im nächsten Schritt richtest du deine Kita ein.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<RegisterForm />
<p className="text-center text-sm text-muted-foreground">
Bereits einen Account?{" "}
<Link href="/login" className="font-medium text-foreground underline">
Anmelden
</Link>
</p>
</CardContent>
</Card>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { registerAction, type RegisterState } from "./actions";
const initialState: RegisterState = {};
export function RegisterForm() {
const [state, formAction, pending] = useActionState(registerAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
id="firstName"
name="firstName"
label="Vorname"
autoComplete="given-name"
required
error={state.errors?.firstName?.[0]}
/>
<FormField
id="lastName"
name="lastName"
label="Nachname"
autoComplete="family-name"
required
error={state.errors?.lastName?.[0]}
/>
</div>
<FormField
id="email"
name="email"
type="email"
label="E-Mail"
autoComplete="email"
required
error={state.errors?.email?.[0]}
/>
<FormField
id="password"
name="password"
type="password"
label="Passwort"
autoComplete="new-password"
required
helperText="Mindestens 8 Zeichen."
error={state.errors?.password?.[0]}
/>
<div className="flex items-start gap-3 pt-2">
<Checkbox id="acceptPrivacyPolicy" name="acceptPrivacyPolicy" required />
<div className="grid gap-1 leading-none">
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
Ich habe die{" "}
<a href="/datenschutz" className="underline" target="_blank" rel="noreferrer">
Datenschutzerklärung
</a>{" "}
gelesen und akzeptiere sie.
</Label>
{state.errors?.acceptPrivacyPolicy?.[0] && (
<p className="text-xs text-destructive">{state.errors.acceptPrivacyPolicy[0]}</p>
)}
</div>
</div>
{state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.errors._form[0]}
</p>
)}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Wird erstellt…" : "Account erstellen"}
</Button>
</form>
);
}
function FormField({
id,
name,
label,
type = "text",
required,
autoComplete,
helperText,
error,
}: {
id: string;
name: string;
label: string;
type?: string;
required?: boolean;
autoComplete?: string;
helperText?: string;
error?: string;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
name={name}
type={type}
required={required}
autoComplete={autoComplete}
aria-invalid={!!error}
/>
{error ? (
<p className="text-xs text-destructive">{error}</p>
) : helperText ? (
<p className="text-xs text-muted-foreground">{helperText}</p>
) : null}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
// =====================================================================
// NextAuth.js (Auth.js v5) · Credentials-Provider mit JWT-Strategie
// ---------------------------------------------------------------------
// Mandantenfähigkeit: `id`, `role`, `kitaId` werden über die JWT-/Session-
// Callbacks aus der DB in jede Session durchgeschleift, damit jede
// Server Action / API-Route den Tenant-Filter setzen kann.
// =====================================================================
const credentialsSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z.string().min(1),
});
// Brute-Force-Schutz auf Account-Ebene (planung.md §5.5).
// Globales Rate-Limiting ergänzen wir später per Middleware.
const MAX_FAILED_ATTEMPTS = 10;
const LOCKOUT_MINUTES = 15;
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "E-Mail", type: "email" },
password: { label: "Passwort", type: "password" },
},
async authorize(rawCredentials) {
const parsed = credentialsSchema.safeParse(rawCredentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
// Account temporär gesperrt → Login pauschal ablehnen.
if (user.lockedUntil && user.lockedUntil > new Date()) {
return null;
}
const passwordOk = await compare(password, user.passwordHash);
if (!passwordOk) {
const nextAttempts = user.failedLoginAttempts + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: nextAttempts,
lockedUntil:
nextAttempts >= MAX_FAILED_ATTEMPTS
? new Date(Date.now() + LOCKOUT_MINUTES * 60_000)
: user.lockedUntil,
},
});
return null;
}
// Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren.
await prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: new Date(),
},
});
return {
id: user.id,
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role,
kitaId: user.kitaId,
};
},
}),
],
callbacks: {
/**
* Wird beim Login (mit `user`) und bei jedem Token-Refresh (ohne `user`)
* aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder
* einmalig in den Token; bei jedem weiteren Aufruf re-validieren wir
* `role` und `kitaId` gegen die DB, damit z.B. ein gerade entzogener
* Admin-Rang sofort greift (kein 30-Tage-Token mit veralteter Rolle).
*/
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.kitaId = user.kitaId;
return token;
}
if (token.id) {
const fresh = await prisma.user.findUnique({
where: { id: token.id },
select: { role: true, kitaId: true },
});
if (!fresh) {
// User wurde gelöscht → Token entwerten.
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
delete (token as Partial<typeof token>).id;
return token;
}
token.role = fresh.role;
token.kitaId = fresh.kitaId;
}
return token;
},
/**
* Wird bei jedem `auth()` / `useSession()`-Aufruf ausgeführt.
* Hier projizieren wir die JWT-Felder in das Session-Objekt,
* damit Server Components / Client Components sie typsicher lesen können.
*/
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id;
session.user.role = token.role;
session.user.kitaId = token.kitaId;
}
return session;
},
},
});
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Users, CalendarCheck2, CalendarDays, Contact, GraduationCap, UserCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { UserRole } from "@prisma/client";
const navItems = [
{
href: "/dashboard",
label: "Übersicht",
icon: LayoutDashboard,
exact: true,
},
{
href: "/dashboard/notdienst",
label: "Notdienst (Eltern)",
icon: CalendarCheck2,
exact: true,
},
{
href: "/dashboard/notdienst/plan",
label: "Notdienst-Planung",
icon: CalendarCheck2,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
href: "/dashboard/kalender",
label: "Terminkalender",
icon: CalendarDays,
exact: false,
},
{
href: "/dashboard/adressbuch",
label: "Adressbuch",
icon: Contact,
exact: false,
},
{
href: "/dashboard/families",
label: "Familienverwaltung",
icon: Users,
exact: false,
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR],
},
{
href: "/dashboard/erzieher",
label: "ErzieherInnen",
icon: GraduationCap,
exact: false,
allowedRoles: [UserRole.ADMIN],
},
{
href: "/dashboard/profil",
label: "Mein Profil",
icon: UserCircle,
exact: false,
},
];
export function SidebarNav({ role }: { role: UserRole }) {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1 px-3">
{navItems.map(({ href, label, icon: Icon, exact, allowedRoles }) => {
// Filter out items not allowed for the current role
if (allowedRoles && !(allowedRoles as UserRole[]).includes(role)) {
return null;
}
const isActive = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
</nav>
);
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+119
View File
@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+26
View File
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
+101
View File
@@ -0,0 +1,101 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+186
View File
@@ -0,0 +1,186 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
type AlertEmailProps = {
date: string;
childName: string;
confirmLink: string;
};
export function AlertEmail({
date,
childName,
confirmLink,
}: AlertEmailProps) {
return (
<Html lang="de">
<Head />
<Preview>Dringender Notdienst-Alarm fuer {date}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.alertBar}>
<Text style={styles.kicker}>Dringend</Text>
<Heading style={styles.heading}>Notdienst heute bestaetigen</Heading>
<Text style={styles.lead}>
Eine Fachkraft ist ausgefallen. Fuer {childName} ist ein
Notdienst-Einsatz am {date} hinterlegt.
</Text>
</Section>
<Section style={styles.card}>
<Text style={styles.text}>
Bitte bestaetige schnell, ob du den Notdienst uebernehmen kannst,
damit die Kita den Tag verlaesslich planen kann.
</Text>
<Button href={confirmLink} style={styles.button}>
Notdienst bestaetigen
</Button>
<Section style={styles.detailBox}>
<Text style={styles.detailLabel}>Einsatzdatum</Text>
<Text style={styles.detailValue}>{date}</Text>
<Text style={styles.detailLabel}>Kind</Text>
<Text style={styles.detailValue}>{childName}</Text>
</Section>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen
Browser:
</Text>
<Text style={styles.linkText}>{confirmLink}</Text>
</Section>
<Hr style={styles.hr} />
<Text style={styles.footer}>
Diese Nachricht wurde automatisch vom Kita-Planer versendet.
</Text>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
margin: 0,
backgroundColor: "#fff7ed",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#30170f",
},
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
},
alertBar: {
padding: "26px 28px",
backgroundColor: "#9f1239",
borderRadius: "8px 8px 0 0",
},
kicker: {
display: "inline-block",
margin: "0 0 18px",
padding: "6px 10px",
backgroundColor: "#fed7aa",
color: "#7f1d1d",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
},
heading: {
margin: "0 0 12px",
color: "#ffffff",
fontSize: "30px",
lineHeight: "1.16",
fontWeight: 850,
},
lead: {
margin: 0,
color: "#ffe4e6",
fontSize: "16px",
lineHeight: "1.55",
},
card: {
padding: "28px",
backgroundColor: "#ffffff",
border: "1px solid #fed7aa",
borderTop: "0",
borderRadius: "0 0 8px 8px",
},
text: {
margin: "0 0 22px",
color: "#44251a",
fontSize: "16px",
lineHeight: "1.65",
},
button: {
display: "inline-block",
padding: "14px 22px",
backgroundColor: "#ea580c",
color: "#ffffff",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 800,
textDecoration: "none",
},
detailBox: {
margin: "28px 0 0",
padding: "18px",
backgroundColor: "#fff7ed",
border: "1px solid #fdba74",
borderRadius: "6px",
},
detailLabel: {
margin: "0 0 4px",
color: "#9a3412",
fontSize: "12px",
fontWeight: 800,
letterSpacing: "0.06em",
textTransform: "uppercase" as const,
},
detailValue: {
margin: "0 0 14px",
color: "#30170f",
fontSize: "16px",
fontWeight: 700,
},
fallbackText: {
margin: "26px 0 8px",
color: "#7c5a4b",
fontSize: "13px",
lineHeight: "1.5",
},
linkText: {
margin: 0,
color: "#be123c",
fontSize: "13px",
lineHeight: "1.5",
wordBreak: "break-all" as const,
},
hr: {
margin: "24px 0",
borderColor: "#fed7aa",
},
footer: {
margin: 0,
color: "#8b6f63",
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
},
};
+155
View File
@@ -0,0 +1,155 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
type InviteEmailProps = {
parentName: string;
kitaName: string;
inviteLink: string;
};
export function InviteEmail({
parentName,
kitaName,
inviteLink,
}: InviteEmailProps) {
return (
<Html lang="de">
<Head />
<Preview>Aktiviere deinen Kita-Planer Account fuer {kitaName}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.header}>
<Text style={styles.brand}>Kita-Planer</Text>
<Heading style={styles.heading}>Willkommen bei {kitaName}</Heading>
<Text style={styles.lead}>
Hallo {parentName}, dein Eltern-Account wurde vorbereitet.
</Text>
</Section>
<Section style={styles.card}>
<Text style={styles.text}>
Ueber den folgenden Link kannst du dein Passwort setzen und deinen
Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
Termine und deine Familiendaten.
</Text>
<Button href={inviteLink} style={styles.button}>
Account aktivieren
</Button>
<Text style={styles.fallbackText}>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen
Browser:
</Text>
<Text style={styles.linkText}>{inviteLink}</Text>
</Section>
<Hr style={styles.hr} />
<Text style={styles.footer}>
Diese Einladung wurde von deiner Kita erstellt. Wenn du sie nicht
erwartet hast, kannst du diese E-Mail ignorieren.
</Text>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
margin: 0,
backgroundColor: "#f6f7f2",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: "#1f2a24",
},
container: {
width: "100%",
maxWidth: "600px",
margin: "0 auto",
padding: "32px 20px",
},
header: {
padding: "28px 28px 20px",
backgroundColor: "#1f3b2d",
borderRadius: "8px 8px 0 0",
},
brand: {
margin: "0 0 28px",
color: "#d8f2bd",
fontSize: "13px",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
},
heading: {
margin: "0 0 12px",
color: "#ffffff",
fontSize: "30px",
lineHeight: "1.18",
fontWeight: 800,
},
lead: {
margin: 0,
color: "#e7f3e6",
fontSize: "16px",
lineHeight: "1.55",
},
card: {
padding: "28px",
backgroundColor: "#ffffff",
borderRadius: "0 0 8px 8px",
border: "1px solid #e0e5dc",
borderTop: "0",
},
text: {
margin: "0 0 24px",
color: "#344139",
fontSize: "16px",
lineHeight: "1.65",
},
button: {
display: "inline-block",
padding: "14px 22px",
backgroundColor: "#f0b84b",
color: "#172119",
borderRadius: "6px",
fontSize: "15px",
fontWeight: 800,
textDecoration: "none",
},
fallbackText: {
margin: "28px 0 8px",
color: "#66736b",
fontSize: "13px",
lineHeight: "1.5",
},
linkText: {
margin: 0,
color: "#2f6b4f",
fontSize: "13px",
lineHeight: "1.5",
wordBreak: "break-all" as const,
},
hr: {
margin: "24px 0",
borderColor: "#dfe5d9",
},
footer: {
margin: 0,
color: "#7b857d",
fontSize: "12px",
lineHeight: "1.5",
textAlign: "center" as const,
},
};
+93
View File
@@ -0,0 +1,93 @@
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { UserRole } from "@prisma/client";
import { auth } from "@/auth";
// =====================================================================
// Auth-Utils für Server Components, Server Actions, Route Handlers
// ---------------------------------------------------------------------
// Wir kapseln die Session-Auflösung an einer Stelle, damit jede
// geschützte Route denselben Sicherheits-Pfad geht:
//
// 1. `getSession()` → optional, gibt `null` zurück
// 2. `requireSession()` → eingeloggt, sonst Redirect /login
// 3. `requireKitaSession()` → eingeloggt + kitaId vorhanden + Privacy-Consent
// 4. `requireRole()` → zusätzlich Rollen-Whitelist
//
// Mandanten-Isolation: NIEMALS `session.user.kitaId` ungeprüft an Prisma
// reichen. Über `requireKitaSession` ist garantiert, dass der Wert
// nicht-null ist und der Consent-Zeitstempel gesetzt wurde.
// =====================================================================
export type AuthenticatedSession = Session & {
user: NonNullable<Session["user"]>;
};
export type KitaSession = AuthenticatedSession & {
user: AuthenticatedSession["user"] & { kitaId: string };
};
export async function getSession(): Promise<Session | null> {
return auth();
}
export async function requireSession(): Promise<AuthenticatedSession> {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return session as AuthenticatedSession;
}
/**
* Garantiert: eingeloggter User MIT Mandantenzuordnung UND akzeptierter
* Datenschutzerklärung. Genau diese Funktion ist der Single-Point-of-Truth
* für alle Tenant-gebundenen Server Actions / Page Components.
*/
export async function requireKitaSession(): Promise<KitaSession> {
const session = await requireSession();
if (!session.user.kitaId) {
// Superadmins haben keine Kita → eigene Oberfläche.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
// Frisch registrierte Gründer → in den Onboarding-Wizard.
redirect("/onboarding");
}
// DSGVO-Gate: Ohne Privacy-Consent → erst Consent einholen.
// Wir prüfen das hier zentral statt in jeder Route einzeln.
const consent = await consentCheck(session.user.id);
if (!consent) {
redirect("/onboarding/consent");
}
return session as KitaSession;
}
export async function requireRole(
allowed: UserRole[],
): Promise<AuthenticatedSession> {
const session = await requireSession();
if (!allowed.includes(session.user.role)) {
redirect("/forbidden");
}
return session;
}
// ---------------------------------------------------------------------
// interne Helfer
// ---------------------------------------------------------------------
async function consentCheck(userId: string): Promise<boolean> {
// Lazy-Import, damit `auth-utils` selbst Edge-kompatibel bleibt
// (Prisma läuft nur in Node-Runtime).
const { prisma } = await import("@/lib/prisma");
const user = await prisma.user.findUnique({
where: { id: userId },
select: { privacyPolicyAcceptedAt: true },
});
return !!user?.privacyPolicyAcceptedAt;
}
+48
View File
@@ -0,0 +1,48 @@
import {
addMonths,
endOfMonth,
getDate,
getYear,
getMonth,
isWeekend,
eachDayOfInterval,
startOfMonth,
startOfDay,
format,
} from "date-fns";
import { de } from "date-fns/locale";
/**
* Der Notdienst-Plan wird immer für den nächsten Monat erstellt.
* Nach dem 26. des aktuellen Monats ist der Plan gesperrt.
*/
const LOCK_DAY = 26;
export function getTargetMonthData(now = new Date()) {
const currentDay = getDate(now);
const nextMonthDate = addMonths(now, 1);
const targetYear = getYear(nextMonthDate);
const targetMonth = getMonth(nextMonthDate) + 1; // 1-12
// Sperre aktiv, sobald der aktuelle Tag (im aktuellen Monat) > LOCK_DAY ist.
const isLocked = currentDay > LOCK_DAY;
return {
targetYear,
targetMonth,
isLocked,
monthName: format(nextMonthDate, "MMMM yyyy", { locale: de }),
};
}
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 days = eachDayOfInterval({ start, end });
return days
.filter((day) => !isWeekend(day))
.map((day) => startOfDay(day));
}
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from "react";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
type SendAppEmailInput = {
to: string | string[];
subject: string;
react: ReactNode;
from?: string;
};
type SendAppEmailResult =
| { success: true; id?: string }
| { success: false; error: string };
export function getAppEmailConfigError() {
if (!process.env.RESEND_API_KEY) {
return "RESEND_API_KEY ist nicht konfiguriert.";
}
if (!process.env.EMAIL_FROM) {
return "EMAIL_FROM ist nicht konfiguriert.";
}
return null;
}
export async function sendAppEmail({
to,
subject,
react,
from = process.env.EMAIL_FROM,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (!process.env.RESEND_API_KEY) {
return {
success: false,
error: "RESEND_API_KEY ist nicht konfiguriert.",
};
}
if (!from) {
return {
success: false,
error: "EMAIL_FROM ist nicht konfiguriert.",
};
}
try {
const { data, error } = await resend.emails.send({
from: from!,
to,
subject,
react,
});
if (error) {
console.error("Resend konnte die E-Mail nicht versenden:", error);
return {
success: false,
error: error.message ?? "E-Mail-Versand fehlgeschlagen.",
};
}
return { success: true, id: data?.id };
} catch (error) {
console.error("Unerwarteter Fehler beim E-Mail-Versand:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Unerwarteter Fehler beim E-Mail-Versand.",
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaClient } from "@prisma/client";
// Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload
// neue Modul-Instanzen → ohne Cache landen schnell zu viele DB-Connections
// im Pool ("Too many connections"). Wir hängen den Client an `globalThis`.
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@/auth";
// =====================================================================
// Kita-Planer · Proxy (ehem. Middleware, seit Next.js 16 umbenannt)
// ---------------------------------------------------------------------
// Optimistischer Guard — prüft den JWT-Cookie direkt, ohne DB-Call.
// Der sichere DB-seitige Check (Consent, Rolle) findet in den
// jeweiligen Server Components via `requireKitaSession()` statt.
//
// Routing-Logik:
// Nicht eingeloggt + geschützte Route → /login
// Eingeloggt + kein kitaId + nicht /onboarding → /onboarding
// Eingeloggt + hat kitaId + auf /onboarding → /dashboard
// Eingeloggt + auf /login oder /register → /
// =====================================================================
const PUBLIC_ROUTES = ["/", "/login", "/register", "/datenschutz"];
const ONBOARDING_ROUTE = "/onboarding";
const PROTECTED_PREFIX = ["/dashboard", "/admin", "/onboarding"];
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Auth.js liest den Session-JWT aus dem Cookie —
// kein DB-Call, pure Token-Verifikation (Edge-sicher).
const session = await auth();
const user = session?.user;
const isPublicRoute = PUBLIC_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + "/"),
);
const isProtectedRoute = PROTECTED_PREFIX.some((prefix) =>
pathname.startsWith(prefix),
);
// ── 1. Nicht eingeloggt + geschützte Route ──────────────────────────
if (!user && isProtectedRoute) {
const loginUrl = new URL("/login", request.nextUrl);
// callbackUrl für spätere Nutzung (optional)
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// ── 2. Eingeloggt ───────────────────────────────────────────────────
if (user) {
// 2a. Eingeloggter User auf Login/Register-Seite → Startseite,
// die dann selbst zu /dashboard oder /onboarding redirectet.
if (pathname === "/login" || pathname === "/register") {
return NextResponse.redirect(new URL("/", request.nextUrl));
}
// 2b. Kein kitaId (heimatloser Admin) → muss Onboarding abschließen.
// Ausnahme: Superadmin hat nie eine kitaId.
if (
!user.kitaId &&
user.role !== "SUPERADMIN" &&
!pathname.startsWith(ONBOARDING_ROUTE)
) {
return NextResponse.redirect(new URL(ONBOARDING_ROUTE, request.nextUrl));
}
// 2c. Hat kitaId und will /onboarding aufrufen → direkt ins Dashboard.
if (user.kitaId && pathname.startsWith(ONBOARDING_ROUTE)) {
return NextResponse.redirect(new URL("/dashboard", request.nextUrl));
}
}
return NextResponse.next();
}
// Proxy auf alle sinnvollen Routen anwenden; statische Dateien,
// API-Routen und Next.js-interne Routen ausschließen.
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
+39
View File
@@ -0,0 +1,39 @@
import type { DefaultSession, DefaultUser } from "next-auth";
import type { UserRole } from "@prisma/client";
// =====================================================================
// Type-Augmentation für NextAuth (Auth.js v5)
// ---------------------------------------------------------------------
// Erzwingt zur Compile-Zeit, dass `id`, `role` und `kitaId` in
// `Session.user` und im JWT verfügbar sind. Damit ist die
// Mandanten-Isolation auf Typ-Ebene abgesichert: Jede Server Action /
// Server Component, die `session.user.kitaId` nutzt, scheitert beim
// Build, falls die Eigenschaft je entfernt wird.
// =====================================================================
declare module "next-auth" {
interface User extends DefaultUser {
id: string;
role: UserRole;
kitaId: string | null;
}
interface Session {
user: {
id: string;
role: UserRole;
kitaId: string | null;
} & DefaultSession["user"];
}
}
// NextAuth v5 re-exportiert die JWT-Typen aus `@auth/core/jwt`.
// Module-Augmentation muss daher dort ansetzen — `next-auth/jwt` würde
// ignoriert.
declare module "@auth/core/jwt" {
interface JWT {
id: string;
role: UserRole;
kitaId: string | null;
}
}