Add non-destructive dev seed on startup
This commit is contained in:
@@ -39,3 +39,8 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
# environment
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|||||||
Generated
+2463
-11
File diff suppressed because it is too large
Load Diff
+35
-4
@@ -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
@@ -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.
|
||||||
@@ -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 1–12.
|
||||||
|
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
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
// NextAuth v5: Die fertigen Handler werden direkt re-exportiert.
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Vendored
+39
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user