Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4702bf5abb | ||
|
|
979f795341 | ||
|
|
2d3b82eb80 | ||
|
|
8b32e4a832 | ||
|
|
a96ba0fd84 | ||
|
|
90b8843cda | ||
|
|
043fed2e46 | ||
|
|
ec3f4f933d | ||
|
|
3ce5638449 | ||
|
|
4cd16773c1 | ||
|
|
74dc2d6b57 | ||
|
|
27c5a6940a | ||
|
|
f28956194c | ||
|
|
e9595fab21 | ||
|
|
f6acb3d271 | ||
|
|
339563bf4d | ||
|
|
9915c8213e | ||
|
|
7eaa198a29 | ||
|
|
cf3fb82afe | ||
|
|
88f9c156fc | ||
|
|
ab7432f3a3 | ||
|
|
84aae1d72e | ||
|
|
beffba65c5 | ||
|
|
ec2e0b01e9 | ||
|
|
89364e9011 | ||
|
|
e7eb43462c | ||
|
|
e25a3bcc04 | ||
|
|
1c5d2f10ac | ||
|
|
99ff978116 | ||
|
|
3fa3b9b108 | ||
|
|
84714b8b32 | ||
|
|
811468a22b | ||
|
|
968d108c28 | ||
|
|
b5eb288104 | ||
|
|
ce1a5c9e42 | ||
|
|
5dca7c76f8 | ||
|
|
6c79e0a8e1 | ||
|
|
935845ce56 | ||
|
|
cb4f7409bc | ||
|
|
fa06da324c | ||
|
|
211bb9e300 | ||
|
|
af2c3b245a | ||
|
|
e8b62f6008 | ||
|
|
380c67963b | ||
|
|
8640001f6f | ||
|
|
0b80c62beb | ||
|
|
9542def530 | ||
|
|
ed6786b6ea | ||
|
|
89cf1dfe84 | ||
|
|
70a50d89ab | ||
|
|
928ddac5b3 | ||
|
|
b0cf3adce6 |
@@ -3,3 +3,9 @@
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
<!-- BEGIN:codex-workflow-rules -->
|
||||
# Codex workflow
|
||||
|
||||
Before making repository changes, Codex should work from a dedicated branch in the Codex worktree. Prefer a `codex/...` branch name when Git refs allow it; if an existing ref blocks that namespace, use a `codex-...` branch name instead.
|
||||
<!-- END:codex-workflow-rules -->
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# UI-ToDo: klickbare Elemente ohne Funktion
|
||||
|
||||
Stand: 2026-05-20
|
||||
|
||||
Geprüft lokal als Admin mit Demo-Login `admin@waldameisen.local`.
|
||||
|
||||
## Notdienst-Planung
|
||||
|
||||
- [x] `src/app/dashboard/notdienst/plan/page.tsx:186`
|
||||
Monat zurück/vor haben keinen Handler oder Link.
|
||||
|
||||
- [x] `src/app/dashboard/notdienst/plan/page.tsx:196`
|
||||
`Einstellungen` ist klickbar, öffnet aber nichts und navigiert nicht.
|
||||
|
||||
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:135`
|
||||
`Erinnerungen verschicken` im Empty-State ist ohne Funktion.
|
||||
|
||||
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:202`
|
||||
`Erinnerung an offene senden (...)` ist ohne Funktion.
|
||||
|
||||
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:312`
|
||||
`Als PDF exportieren` ist ohne Funktion.
|
||||
|
||||
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:316`
|
||||
`Plan zurücksetzen` ist ohne Funktion.
|
||||
|
||||
## Terminkalender
|
||||
|
||||
- [x] `src/app/dashboard/kalender/page.tsx:296`
|
||||
Umschalter `Liste / Monat / Agenda` ändern keine Ansicht.
|
||||
|
||||
- [x] `src/app/dashboard/kalender/page.tsx:317`
|
||||
Suche `Termine suchen` filtert aktuell nicht.
|
||||
|
||||
- [x] `src/app/dashboard/kalender/page.tsx:415`
|
||||
Kategorie-Filterchips sind nur visuell.
|
||||
|
||||
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:152`
|
||||
`Mehr Optionen` bei Elterndiensten ist ohne Funktion.
|
||||
|
||||
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:191`
|
||||
`Mehr Optionen` bei Terminen ist ohne Funktion.
|
||||
|
||||
## Adressbuch
|
||||
|
||||
- [x] `src/app/dashboard/adressbuch/page.tsx:161`
|
||||
`Sichtbarkeit` ist ohne Funktion.
|
||||
|
||||
- [x] `src/app/dashboard/adressbuch/page.tsx:193`
|
||||
Suche `Familien, Namen oder E-Mail suchen` filtert aktuell nicht.
|
||||
|
||||
- [x] `src/app/dashboard/adressbuch/page.tsx:201`
|
||||
Filterchips `Alle Gruppen`, `Mit Telefon`, `Mit Ämtern`, `Nur Opt-in` sind nur visuell.
|
||||
|
||||
- [x] `src/app/dashboard/adressbuch/page.tsx:334`
|
||||
`Kontakt öffnen` ist ohne Funktion.
|
||||
|
||||
## Familienverwaltung
|
||||
|
||||
- [x] `src/app/dashboard/families/page.tsx:117`
|
||||
`Ansicht` ist ohne Funktion.
|
||||
|
||||
- [x] `src/app/dashboard/families/page.tsx:133`
|
||||
Suche `Familien, Eltern oder Kinder suchen` filtert aktuell nicht.
|
||||
|
||||
## Abwesenheiten Admin
|
||||
|
||||
- [x] `src/app/dashboard/admin/abwesenheiten/page.tsx:132`
|
||||
Filterchips `Alle Gruppen` und `Alle Gründe` sind klickbar, aber ohne Funktion.
|
||||
|
||||
## Bereits angebunden / nicht auffaellig
|
||||
|
||||
- Dialoge und Mutationsaktionen sind bei der Pruefung nicht als No-Op aufgefallen:
|
||||
- Familie anlegen/bearbeiten
|
||||
- ErzieherIn anlegen/bearbeiten/loeschen
|
||||
- Profil speichern
|
||||
- Termin anlegen/anfragen
|
||||
- Dienstplan-Zuweisungen
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Deployment und Mailversand – Anleitung für den Kita-Planer
|
||||
|
||||
Stand: 09.10.2026. Grundlage sind die aktuelle Docker-/Coolify-Konfiguration und die umgesetzten Funktionen für Personal und Notdienstalarm.
|
||||
|
||||
## 1. Vor dem Deployment festlegen
|
||||
|
||||
- **App-Adresse:** eine feste HTTPS-Adresse, zum Beispiel `https://app.example.org`.
|
||||
- **Datenbank:** entweder PostgreSQL aus dem mitgelieferten Compose-Stack oder eine bereits bestehende Datenbank.
|
||||
- **Versanddomain:** eine eigene bei Resend verifizierte Domain, zum Beispiel `notify.example.org`.
|
||||
- **Absender:** zum Beispiel `Kita-Planer <noreply@notify.example.org>`.
|
||||
- **Kontaktpostfach:** eine tatsächlich erreichbare Adresse für `ADMIN_EMAIL`.
|
||||
- **Sicherung:** Datenbank und Upload-Dateien vor einem Update sichern; die Wiederherstellung sollte bekannt sein.
|
||||
|
||||
Die Beispieladressen und Platzhalter in dieser Anleitung müssen durch deine Werte ersetzt werden. Zugangsdaten werden in der Hosting-Konfiguration hinterlegt und nicht ins Repository geschrieben.
|
||||
|
||||
## 2. Coolify und Datenbank richtig verbinden
|
||||
|
||||
In Coolify den gewünschten Release-Branch aus Gitea auswählen, **Docker Compose** als Build Pack und `docker-compose.coolify.yml` als Compose-Datei verwenden. Die aktuelle Implementierung des Notdienstalarms liegt auf `codex/notdienstalarm`; der ausgewählte Deployment-Stand muss diese Änderungen enthalten. Die öffentliche Domain dem Dienst **app**, internem Port **3000**, zuordnen und HTTPS aktivieren.
|
||||
|
||||
### Variante A: Datenbank des mitgelieferten Compose-Stacks
|
||||
|
||||
Die vorhandene Compose-Datei startet `postgres`, `app` und `pgadmin`. Für die Datenbank werden `POSTGRES_DB`, `POSTGRES_USER` und `POSTGRES_PASSWORD` gesetzt. Der App-Dienst erhält daraus automatisch:
|
||||
|
||||
```text
|
||||
postgresql://POSTGRES_USER:POSTGRES_PASSWORD@postgres:5432/POSTGRES_DB?schema=public
|
||||
```
|
||||
|
||||
`postgres` ist hier der interne Dienstname. Eine zusätzlich in Coolify eingetragene Variable `DATABASE_URL` wird von der aktuellen Compose-Datei **nicht** verwendet, weil die URL dort direkt zusammengesetzt wird. Bei Sonderzeichen im Datenbankpasswort muss die URL korrekt kodiert sein; alternativ die Compose-Konfiguration auf eine ausdrücklich bereitgestellte, korrekt kodierte `DATABASE_URL` umstellen.
|
||||
|
||||
### Variante B: Bereits vorhandene Coolify-PostgreSQL-Datenbank
|
||||
|
||||
Die Compose-Datei muss vor dem Deployment angepasst werden: Der App-Dienst soll die hinterlegte `DATABASE_URL` übernehmen, beispielsweise über `DATABASE_URL: ${DATABASE_URL:?}`. Die Abhängigkeit von dem bisherigen `postgres`-Dienst ist entsprechend anzupassen; einen unbenötigten separaten Datenbankdienst vermeiden. Falls pgAdmin weiter genutzt wird, dessen Konfiguration und Abhängigkeit ebenfalls prüfen.
|
||||
|
||||
Die App muss die bestehende Datenbank über das interne Coolify-Netzwerk erreichen können. `localhost` in der App-URL würde auf den App-Container zeigen. Vor dem Start verifizieren, dass die URL wirklich auf die gewünschte Datenbank zeigt; der Start wendet das Anwendungsschema auf genau diese Datenbank an.
|
||||
|
||||
## 3. Umgebungsvariablen
|
||||
|
||||
Die ausfüllbare Vorlage ist [production.env.example](production.env.example). In Coolify werden Werte normalerweise ohne die Anführungszeichen einer `.env`-Datei eingetragen.
|
||||
|
||||
| Variable | Was du eintragen bzw. prüfen musst |
|
||||
| --- | --- |
|
||||
| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | Für den mitgelieferten Datenbankdienst. Bestehende Zugangsdaten bei Updates beibehalten; Werte mit der tatsächlichen Datenbank abgleichen. |
|
||||
| `DATABASE_URL` | Bei einer angepassten Compose-Datei bzw. anderem Hosting: interne PostgreSQL-URL einschließlich Zugangsdaten und Schema. |
|
||||
| `AUTH_SECRET` | Einmalig zufällig erzeugen und dauerhaft speichern; nicht bei jedem Deployment ersetzen. Ein Wechsel beendet bestehende Sessions. |
|
||||
| `AUTH_URL` | Die öffentliche HTTPS-App-Adresse. |
|
||||
| `NEXTAUTH_URL` | Dieselbe öffentliche Adresse; bestehende Einladungsabläufe verwenden auch diesen Namen. |
|
||||
| `NEXT_PUBLIC_SITE_URL` | Dieselbe öffentliche Adresse, ohne abschließenden Slash und ohne Unterpfad. Für Mail-Links und öffentliche URLs erforderlich. |
|
||||
| `RESEND_API_KEY` | Gültiger Resend-Schlüssel mit Versandberechtigung für die gewählte Domain. |
|
||||
| `EMAIL_FROM` | Anzeigename und Adresse der verifizierten Versanddomain, beispielsweise `Kita-Planer <noreply@notify.example.org>`. |
|
||||
| `ADMIN_EMAIL` | Empfänger der Kontaktformular-Anfragen. Diese Variable ersetzt nicht die Kita-Admin-Konten für Personal- oder Notdiensthinweise. |
|
||||
| `NOTDIENST_JOB_SECRET` | Eigenes zufälliges Secret mit mindestens 32 Zeichen für die automatische Notdienst-Fristprüfung. |
|
||||
| `UPLOAD_DIR` | Dauerhaft gespeichertes Verzeichnis für Anhänge; im aktuellen Compose-Stack `/app/uploads/announcements`. |
|
||||
| `PGADMIN_DEFAULT_EMAIL`, `PGADMIN_DEFAULT_PASSWORD` | Nur erforderlich, wenn der mitgelieferte pgAdmin-Dienst genutzt wird. |
|
||||
| `NEXT_PUBLIC_UMAMI_*` | Optional. Ohne Analytics leer lassen. |
|
||||
|
||||
Zwei getrennte Secrets lassen sich lokal erzeugen:
|
||||
|
||||
```sh
|
||||
# AUTH_SECRET
|
||||
openssl rand -base64 32
|
||||
|
||||
# NOTDIENST_JOB_SECRET – einen anderen Wert verwenden
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Die ausgegebenen Werte sicher in Coolify hinterlegen. `NEXT_PUBLIC_SITE_URL` muss im aktuellen Docker-Ablauf sowohl beim **Build** als auch zur **Laufzeit** vorhanden sein. Nach einer Domainänderung neu bauen und deployen. Nach Änderungen an Mail-Schlüssel oder Absender die App neu starten, damit die neue Konfiguration verwendet wird.
|
||||
|
||||
Für Produktion gilt `NODE_ENV=production`. `TEST_MODE` und `ALLOW_DATABASE_SEED` bleiben ungesetzt. Der vorhandene Befehl `npm run dev` führt einen Schemaabgleich und Demo-Seeding aus und ist deshalb kein Produktionsstart.
|
||||
|
||||
## 4. Schema, Start und dauerhafte Dateien
|
||||
|
||||
Der Docker-Build installiert Abhängigkeiten, generiert den Prisma-Client und erstellt den Produktionsbuild. Beim Container-Start führt `scripts/start-production.sh` aus:
|
||||
|
||||
```sh
|
||||
npx prisma db push --skip-generate
|
||||
```
|
||||
|
||||
Danach startet es den Notdienst-Worker und die App. Das Projekt besitzt weiterhin keine vollständige versionierte Prisma-Migrationshistorie. Vor Updates Schemaänderungen und Startlogs prüfen; bei einem fehlgeschlagenen Schemaabgleich die Ursache klären, bevor das Deployment fortgesetzt wird.
|
||||
|
||||
Für kontrollierte, einmalige SQL-Upgrades gibt es diese Alternativen:
|
||||
|
||||
| Ausgangsstand | Passendes SQL-Upgrade |
|
||||
| --- | --- |
|
||||
| Personalbereich noch nicht vorhanden, Stand passend zur Einführung | [personalbereich.sql](sql/personalbereich.sql) führt den vollständigen Personalbereich **einschließlich** Mail-Erweiterung ein. |
|
||||
| Personalbereich bereits auf `043fed2`, Mail-Erweiterung noch nicht vorhanden | Nur [personalbereich-email.sql](sql/personalbereich-email.sql). |
|
||||
| Personalbereich einschließlich Mail-Erweiterung vorhanden, Notdienstalarm noch nicht vorhanden | [notdienstalarm.sql](sql/notdienstalarm.sql). |
|
||||
|
||||
`personalbereich.sql` und `personalbereich-email.sql` sind **keine** aufeinanderfolgende Skriptfolge: Das erste enthält die Mail-Erweiterung bereits. Die SQL-Skripte sind nicht für wiederholte Ausführung gedacht. Hat `db push` die betreffende Erweiterung schon angewandt, das entsprechende SQL nicht zusätzlich ausführen.
|
||||
|
||||
Im Compose-Stack sichern die Volumes `kita_planer_postgres_data` und `kita_planer_uploads` Datenbank und Anhänge über Containerwechsel hinweg. Bei Nutzung von pgAdmin kommt dessen eigenes Volume hinzu. Die Anwendung hängt `announcements` an `UPLOAD_DIR` an; im aktuellen Compose-Setup liegen Dateien deshalb unter `/app/uploads/announcements/announcements`. Dieser Pfad ist vom Volume `/app/uploads` abgedeckt. Bei einem Wechsel des Speicherpfads bestehende Dateien und Sicherungen berücksichtigen.
|
||||
|
||||
Bei anderem Hosting sind Build, Schemaabgleich, App-Start und Worker separat sicherzustellen. `npm run start` bzw. `npm run start:prod` allein starten nur die App, wenden kein Schema an und starten keinen Notdienst-Worker.
|
||||
|
||||
## 5. Mailversand mit Resend einrichten
|
||||
|
||||
Die Anwendung versendet über die **Resend-API**. SMTP-Host, SMTP-Port oder ein normales Postfachpasswort werden dafür nicht verwendet.
|
||||
|
||||
1. Im Resend-Dashboard eine eigene Versanddomain hinzufügen. Eine Subdomain wie `notify.example.org` hält den Transaktionsversand übersichtlich getrennt; auch Resend empfiehlt Subdomains für den Versand.
|
||||
2. Die von Resend angezeigten DNS-Einträge für SPF/DKIM einschließlich der dort verlangten TXT-, MX- oder CNAME-Einträge exakt beim DNS-Anbieter übernehmen. Resend-CNAMEs als DNS-Einträge ohne Proxy konfigurieren. Keine generischen DNS-Werte aus dieser Anleitung einsetzen.
|
||||
3. Im Resend-Dashboard die erfolgreiche Verifizierung abwarten.
|
||||
4. `EMAIL_FROM` auf eine Adresse genau dieser Versanddomain setzen. Bei Verifizierung von `notify.example.org` beispielsweise `noreply@notify.example.org` verwenden.
|
||||
|
||||
Die aktuellen Schritte beschreibt die offizielle [Resend-Anleitung zur Domain-Verifizierung](https://resend.com/docs/add-a-domain).
|
||||
|
||||
5. DMARC passend zum bestehenden Mailbetrieb konfigurieren. Die [Resend-DMARC-Anleitung](https://resend.com/docs/dashboard/domains/dmarc) erklärt die Einrichtung; bestehende Postfach- und DNS-Konfiguration berücksichtigen.
|
||||
6. Einen Versand-API-Schlüssel anlegen, `RESEND_API_KEY` in Coolify setzen und die App neu starten.
|
||||
7. Für Einladungs- und Rückmeldelinks Open-/Click-Tracking ausgeschaltet lassen. Das ist unsere Betriebsempfehlung für diese Transaktionsmails. Tracking ist bei Resend standardmäßig deaktiviert und kann Domains separat zugeordnet werden. Siehe [Resend: Open and Click Tracking](https://resend.com/docs/dashboard/domains/tracking).
|
||||
|
||||
`onboarding@resend.dev` ist für den regulären Versand an Familien ungeeignet: Mit der Testdomain darf nur an die zum Resend-Konto gehörende Adresse gesendet werden. Für andere Empfänger braucht es die eigene verifizierte Domain. Siehe [Resend: Testdomain und 403-Fehler](https://resend.com/docs/knowledge-base/403-error-resend-dev-domain).
|
||||
|
||||
`ADMIN_EMAIL` sollte ein betreutes Postfach sein. Für andere Absenderadressen separat entscheiden, ob Antworten empfangen werden sollen; die Versandkonfiguration allein richtet in der Anwendung kein betreutes Postfach ein. Im Kontaktformular setzt die App Reply-To für Anfrage und Eingangsbestätigung. Die aktuellen Personal- und Notdienstmails setzen kein eigenes Reply-To und führen die Empfänger über die Links in die App.
|
||||
|
||||
In Entwicklung ohne API-Schlüssel simuliert der Mailtransport den Versand und meldet Erfolg. Ein funktionierender lokaler Ablauf belegt daher noch keine echte Zustellung. In Produktion bleibt fehlgeschlagener Versand als Fehler bzw. ausstehender Auftrag sichtbar. Mit einem vorhandenen echten API-Schlüssel kann auch die Entwicklungsumgebung echte E-Mails verschicken.
|
||||
|
||||
## 6. Wer erhält welche E-Mails?
|
||||
|
||||
| Anlass | Empfänger / Verhalten |
|
||||
| --- | --- |
|
||||
| Kontaktformular | `ADMIN_EMAIL` erhält die Anfrage; die anfragende Person erhält eine Eingangsbestätigung. |
|
||||
| Eltern-Einladung | Hinterlegte Elternadresse mit persönlichem Einladungslink. |
|
||||
| Fachkraft aktivieren | Hinterlegte Fachkraftadresse; der Aktivierungslink gilt sieben Tage. Ein neuer Link ersetzt den alten. |
|
||||
| Personal-Antrag / eingereichte Arbeitswoche | Alle zuständigen Kita-Admins derselben Kita. |
|
||||
| Personal-Entscheidung | Betroffene Fachkraft, sofern Entscheidungsmails im Profil eingeschaltet sind. Aktivierungsmails bleiben davon unabhängig. |
|
||||
| Notdienstalarm auslösen | Elternkonten der eingeteilten Familie; jedes Konto erhält einen eigenen Link. |
|
||||
| Notdienst-Rückmeldung / Absage / Fristablauf | Kita-Admins und Notdienst-Koordination derselben Kita. |
|
||||
| Notdienst-Stornierung / dokumentierte Statusänderung | Betroffener Haushalt; bei dokumentierter Rückmeldung erhalten auch Admins und Koordination einen Hinweis. |
|
||||
|
||||
Private Personalnotizen, Kontostände und interne Begründungen sowie interne Notdienst-Fachkraftdaten werden nicht in diese Personal-/Notdienstmails übernommen. Die Auswahl der Kita-Empfänger erfolgt anhand der Konten und Rollen in der Datenbank; `ADMIN_EMAIL` ist hierfür kein Sammelverteiler.
|
||||
|
||||
## 7. Automatische Notdienst-Fristprüfung und Wiederholungen
|
||||
|
||||
Mit gültigem `NOTDIENST_JOB_SECRET` startet der Docker-Produktionsstart `scripts/notdienst-alarm-worker.mjs`. Dieser ruft nach fünf Sekunden und danach ungefähr jede Minute die lokale Route `POST /api/jobs/notdienstalarme` auf. Die voreingestellte Antwortfrist beträgt 30 Minuten; die Meldung erfolgt beim nächsten erfolgreichen Joblauf nach Fristende, nicht sekundengenau.
|
||||
|
||||
Bei anderem Hosting den Worker dauerhaft und mit automatischem Neustart betreiben oder einen Scheduler mindestens einmal pro Minute die Jobroute per POST mit `Authorization: Bearer <NOTDIENST_JOB_SECRET>` aufrufen lassen. Der Prozess darf nicht davon abhängen, dass ein Browser geöffnet ist. Ein gesetztes Secret allein beweist keinen laufenden Worker.
|
||||
|
||||
| Bereich | Was passiert nach einem Versandfehler? |
|
||||
| --- | --- |
|
||||
| Notdienstalarm | Auftrag bleibt erhalten; der Job versucht den Versand erneut. In der Alarmansicht ist zusätzlich ein manueller erneuter Versuch möglich. Die Antwortfrist startet dabei nicht neu. |
|
||||
| Personalbereich | Auftrag bleibt erhalten; über **Versand erneut versuchen** in der Oberfläche wiederholen. Aktuell gibt es hierfür keinen eigenen Hintergrundjob; der Notdienst-Worker bearbeitet keine Personalmails. |
|
||||
| Andere bestehende Mailabläufe | Nicht alle besitzen eine dauerhafte Versandwarteschlange. Den konkreten Fehler und den Stand von Einladung/Aktion prüfen, bevor der Ablauf erneut ausgelöst wird. |
|
||||
|
||||
Ein Alarm-Job verarbeitet höchstens 100 Fristfälle und 100 ausstehende E-Mail-Aufträge pro Lauf. Bei größerem Rückstand sind weitere Läufe nötig. Die gespeicherte Aktion bleibt bei einem Mailfehler erhalten. Resend-Annahme bedeutet nicht, dass die Nachricht bereits im Postfach angekommen ist; eine automatische Auswertung von Zustell-/Bounce-Webhooks ist im geprüften Anwendungscode nicht angebunden.
|
||||
|
||||
## 8. Nach dem Deployment prüfen
|
||||
|
||||
Die Mailprüfung mit eigens dafür eingerichteten Konten und Postfächern durchführen. Bei einer Notdienstprüfung eine Test-Kita und eine Test-Einteilung verwenden, damit keine regulären Familien versehentlich alarmiert werden.
|
||||
|
||||
- [ ] HTTPS, Startseite, Anmeldung und Dashboard erreichbar.
|
||||
- [ ] Build- und Startlogs ohne Schemafehler; App verwendet die richtige Datenbank.
|
||||
- [ ] Anhänge nach einem Containerneustart weiterhin verfügbar.
|
||||
- [ ] Kontaktformular: Anfrage kommt bei `ADMIN_EMAIL` an und Eingangsbestätigung im Testpostfach.
|
||||
- [ ] Eltern- und Fachkrafteinladung: korrekte öffentliche Domain im Link; Aktivierung funktioniert.
|
||||
- [ ] Personal-Antrag: Kita-Admins erhalten den Hinweis; Entscheidung erreicht die Fachkraft. Abschalteinstellung im Profil geprüft.
|
||||
- [ ] Test-Notdienstalarm: nur die eingeteilte Testfamilie erhält die Anfrage; eine Haushaltsantwort aktualisiert den gemeinsamen Status.
|
||||
- [ ] Test-Fristablauf bei geschlossenem Browser: genau ein Hinweis pro zuständigem Admin/Koordinator, beim nächsten Joblauf nach Fristende.
|
||||
- [ ] Stornierung informiert den Testhaushalt; alter Link kann keine Antwort mehr speichern.
|
||||
- [ ] Resend-Dashboard und echte Postfächer einschließlich Spamordner geprüft; keine ausstehenden fehlgeschlagenen Aufträge übersehen.
|
||||
- [ ] Resend-Kontingente, API-Fehler und Bounces im laufenden Betrieb überwacht.
|
||||
|
||||
## 9. Häufige Fehler
|
||||
|
||||
| Beobachtung | Zuerst prüfen |
|
||||
| --- | --- |
|
||||
| Mail fehlt trotz lokaler Erfolgsmeldung | War es Entwicklungsmodus ohne API-Schlüssel? Dann wurde nur simuliert. |
|
||||
| Link zeigt auf localhost oder die falsche Domain | Alle drei App-URLs angleichen, `NEXT_PUBLIC_SITE_URL` auch beim Build setzen und neu deployen. Schon versendete Links ändern sich dadurch nicht rückwirkend. |
|
||||
| Resend lehnt Versand ab | API-Schlüssel, verifizierte Domain, `EMAIL_FROM`, Versandberechtigung und Kontingente prüfen. Bei 403 auch die Testdomain prüfen. |
|
||||
| Resend hat angenommen, Postfach bleibt leer | Resend-Zustellstatus, Bounce/Suppression, Empfängeradresse und Spamordner prüfen. |
|
||||
| Personalaktion gespeichert, Mail ausstehend | Konfiguration beheben, dann im Personalbereich den Versand erneut versuchen. |
|
||||
| Notdienst-Frist ist abgelaufen, kein Hinweis kommt | Worker/Scheduler, Logs und `NOTDIENST_JOB_SECRET` prüfen; Browseranzeige und tatsächliche Jobausführung sind getrennt. |
|
||||
| Jobroute liefert 401 | Bearer-Header fehlt oder Secret stimmt nicht. |
|
||||
| Jobroute liefert 503 | Secret fehlt oder ist kürzer als 32 Zeichen. |
|
||||
| Jobroute liefert 500 | Joblauf fehlgeschlagen; Serverlogs und Datenbank prüfen. |
|
||||
| Jobroute liefert 200, dennoch fehlen Mails | Das Ergebnis kann fehlgeschlagene Versandaufträge enthalten. Versandübersicht und Resend-Konfiguration prüfen. |
|
||||
| Deployment startet nicht | Required-Variablen der Compose-Datei, Datenbankzugriff, Schemaabgleich und Secret-Länge prüfen. |
|
||||
|
||||
Ergänzende Details: [Notdienstalarm](../docs/NOTDIENSTALARM.md), [Personalbereich](../docs/PERSONALBEREICH.md), [Produktionscheckliste](production-checklist.md). Die produktive Hosting- und Resend-Konfiguration wurde für diese Anleitung nicht verändert; sie dokumentiert die noch auszuführenden Einrichtungsschritte.
|
||||
+8
-2
@@ -2,6 +2,8 @@
|
||||
|
||||
Dieses Verzeichnis enthaelt die ausfuellbaren Vorlagen fuer das Production-Deployment.
|
||||
|
||||
Die zentrale Schritt-für-Schritt-Anleitung steht in [DEPLOYMENT_UND_MAILVERSAND.md](DEPLOYMENT_UND_MAILVERSAND.md). Sie erklärt die Datenbankvarianten, alle benötigten Variablen, Resend und DNS, Fristprüfung, Versandwiederholungen und die Prüfung nach dem Deployment.
|
||||
|
||||
## Dateien
|
||||
|
||||
- `production.env.example`: Vorlage fuer alle benoetigten Environment Variables.
|
||||
@@ -15,7 +17,7 @@ Dieses Verzeichnis enthaelt die ausfuellbaren Vorlagen fuer das Production-Deplo
|
||||
2. In Coolify eine neue Application aus diesem Gitea-Repository anlegen.
|
||||
3. Als Build Pack `Docker Compose` waehlen.
|
||||
4. Als Compose-Datei `docker-compose.coolify.yml` eintragen.
|
||||
5. Die bestehende Coolify-Postgres-Datenbank ueber ihre interne `DATABASE_URL` verbinden.
|
||||
5. Datenbankvariante wählen: Die aktuelle Compose-Datei bringt PostgreSQL mit und setzt daraus die App-URL zusammen. Für eine bestehende Coolify-Datenbank zuerst die Compose-Konfiguration auf deren `DATABASE_URL` und Netzwerkverbindung anpassen; siehe zentrale Anleitung.
|
||||
6. Environment Variables aus `production.env.example` in Coolify eintragen.
|
||||
7. Domain auf den `app`-Service mit internem Port `3000` zeigen lassen.
|
||||
8. Deploy starten.
|
||||
@@ -23,13 +25,17 @@ Dieses Verzeichnis enthaelt die ausfuellbaren Vorlagen fuer das Production-Deplo
|
||||
Beim Container-Start fuehrt `scripts/start-production.sh` automatisch aus:
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
npx prisma db push --skip-generate
|
||||
```
|
||||
|
||||
Coolify erkennt Variablen aus `docker-compose.coolify.yml` und zeigt required Variablen
|
||||
wie `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `NEXTAUTH_URL`, `NEXT_PUBLIC_SITE_URL`,
|
||||
`RESEND_API_KEY`, `EMAIL_FROM` und `ADMIN_EMAIL` in der UI an.
|
||||
|
||||
## Notdienstalarm
|
||||
|
||||
Die Compose-Vorlage benötigt zusätzlich `NOTDIENST_JOB_SECRET` (zufällig, mindestens 32 Zeichen). Der Produktionsstart führt die Fristprüfung über einen lokalen Hintergrundprozess ungefähr jede Minute aus. Ein alleiniger Start mit `npm run start` benötigt einen separat gestarteten Worker oder einen Scheduler. Details, Versandprüfung und das additive SQL-Upgrade stehen in [docs/NOTDIENSTALARM.md](../docs/NOTDIENSTALARM.md).
|
||||
|
||||
## Welche Coolify-Postgres-URL?
|
||||
|
||||
Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
## 2. Datenbank
|
||||
|
||||
- [ ] Interne `DATABASE_URL` der bestehenden Coolify-Postgres-DB kopieren
|
||||
- [ ] `DATABASE_URL` in der Coolify-App eintragen
|
||||
- [ ] Datenbankvariante wählen: mitgelieferter Compose-Dienst oder vorhandene Datenbank
|
||||
- [ ] Bei vorhandener Datenbank Compose-Konfiguration und Netzwerkverbindung auf deren interne `DATABASE_URL` anpassen; die unveränderte Vorlage verwendet den eigenen `postgres`-Dienst
|
||||
- [ ] Beim ersten App-Start wird das Schema automatisch mit `npx prisma db push --skip-generate` angewendet
|
||||
- [ ] Fuer spaetere Releases idealerweise Prisma-Migrationen einfuehren
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
- [ ] Dependencies installieren: `npm ci`
|
||||
- [ ] Production Build erstellen: `npm run build:prod`
|
||||
- [ ] App starten: `npm run start:prod`
|
||||
- [ ] Produktionsstart mit Schemaabgleich und Notdienst-Worker verwenden: `scripts/start-production.sh`; bei anderem Hosting diese Schritte separat sicherstellen
|
||||
- [ ] Startseite, Login, Dashboard und Kontaktformular testen
|
||||
|
||||
## 8. Coolify
|
||||
@@ -58,3 +58,12 @@
|
||||
- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen
|
||||
- [ ] Domain auf den `app`-Service und Container-Port `3000` routen
|
||||
- [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten
|
||||
|
||||
## Notdienstalarm – ergänzende Betriebsprüfung
|
||||
|
||||
- [ ] `NOTDIENST_JOB_SECRET` zufällig erzeugt, mindestens 32 Zeichen, nur als Runtime-Secret gespeichert.
|
||||
- [ ] Schema-Upgrade gesichert und angewandt; `db push` oder einmaliges SQL-Upgrade, nicht beides hintereinander.
|
||||
- [ ] Worker bzw. Scheduler startet nach Neustart automatisch und ruft die geschützte Jobroute jede Minute auf.
|
||||
- [ ] Fristablauf mit geschlossenem Browser geprüft: einmaliger Hinweis an Admins und Koordination.
|
||||
- [ ] Versanddienst und öffentliche App-Adresse geprüft; Rückmeldelinks erreichen die tatsächliche Domain.
|
||||
- [ ] Versandübersicht auf Fehler geprüft; Annahme durch den Maildienst und Postfachzustellung getrennt geprüft.
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
# Keine echten Secrets committen.
|
||||
# =====================================================================
|
||||
|
||||
# PostgreSQL
|
||||
# In Coolify am besten die interne URL deiner bestehenden Postgres-DB verwenden.
|
||||
# Beispiel: postgresql://USER:PASSWORD@HOST:5432/kita_planer?schema=public
|
||||
DATABASE_URL="postgresql://[USER]:[PASSWORD]@[HOST]:[PORT]/[DATABASE]?schema=public"
|
||||
# PostgreSQL im Coolify-Compose-Stack
|
||||
POSTGRES_DB="kita_planer"
|
||||
POSTGRES_USER="kita_planer"
|
||||
POSTGRES_PASSWORD="[GENERATE_ME]"
|
||||
|
||||
# pgAdmin im Coolify-Compose-Stack
|
||||
PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]"
|
||||
PGADMIN_DEFAULT_PASSWORD="[GENERATE_ME]"
|
||||
|
||||
# Auth.js / NextAuth
|
||||
# Generieren mit: openssl rand -base64 32
|
||||
@@ -32,6 +36,10 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
|
||||
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
|
||||
ADMIN_EMAIL="[kontakt@deine-domain.de]"
|
||||
|
||||
# Automatische Notdienst-Fristpruefung im App-Container.
|
||||
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
|
||||
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
||||
|
||||
# Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett.
|
||||
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
|
||||
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
-- Additives Upgrade für einen Personalbereich einschließlich vorhandener Mail-Erweiterung.
|
||||
-- Einmalig vor dem Start der neuen Anwendung ausführen, sofern nicht prisma db push verwendet wird.
|
||||
BEGIN;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstAlarmStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'LATE_CONFIRMATION', 'REVIEW_REQUIRED', 'CANCELLED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "notdienst_alerts" ADD COLUMN "alarmId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarms" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"assignmentId" TEXT,
|
||||
"activeAssignmentId" TEXT,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"familyName" TEXT NOT NULL,
|
||||
"childName" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"wholeDay" BOOLEAN NOT NULL DEFAULT true,
|
||||
"startMinute" INTEGER,
|
||||
"endMinute" INTEGER,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"deadline" TIMESTAMP(3) NOT NULL,
|
||||
"status" "NotdienstAlarmStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"triggeredById" TEXT,
|
||||
"triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"respondedById" TEXT,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"responseSource" TEXT,
|
||||
"escalatedAt" TIMESTAMP(3),
|
||||
"educatorId" TEXT,
|
||||
"notes" TEXT,
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"cancelReason" TEXT,
|
||||
|
||||
CONSTRAINT "notdienst_alarms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_recipients" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_recipients_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventKey" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"readAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "notdienst_alarm_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventKey" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarms_activeAssignmentId_key" ON "notdienst_alarms"("activeAssignmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarms_kitaId_date_idx" ON "notdienst_alarms"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarms_status_deadline_idx" ON "notdienst_alarms"("status", "deadline");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_recipients_token_key" ON "notdienst_alarm_recipients"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_recipients_alarmId_userId_key" ON "notdienst_alarm_recipients"("alarmId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_notifications_userId_readAt_idx" ON "notdienst_alarm_notifications"("userId", "readAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_notifications_alarmId_userId_eventKey_key" ON "notdienst_alarm_notifications"("alarmId", "userId", "eventKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_emails_sentAt_skippedAt_createdAt_idx" ON "notdienst_alarm_emails"("sentAt", "skippedAt", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_emails_alarmId_userId_eventKey_key" ON "notdienst_alarm_emails"("alarmId", "userId", "eventKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_history_alarmId_createdAt_idx" ON "notdienst_alarm_history"("alarmId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_history" ADD CONSTRAINT "notdienst_alarm_history_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Nur E-Mail-Erweiterung für einen bereits mit 043fed2 eingeführten Personalbereich.
|
||||
-- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
|
||||
BEGIN;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"inviteToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,286 @@
|
||||
-- Einmalige Einführung des vollständigen Personalbereichs inklusive E-Mails ab ec3f4f9.
|
||||
-- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
|
||||
BEGIN;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCEL_REQUESTED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffLeaveKind" AS ENUM ('VACATION', 'TIME_OFF', 'SICKNESS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffWeekStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'APPROVED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"educatorId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"employmentStart" DATE NOT NULL,
|
||||
"employmentEnd" DATE,
|
||||
"trackingStart" DATE NOT NULL,
|
||||
"openingMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"inviteToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_work_patterns" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"effectiveFrom" DATE NOT NULL,
|
||||
"dailyMinutes" INTEGER[],
|
||||
|
||||
CONSTRAINT "staff_work_patterns_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_day_overrides" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_day_overrides_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_holidays" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_holidays_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leave_accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"entitlementUnits" INTEGER NOT NULL,
|
||||
"carryUnits" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "staff_leave_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leaves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"kind" "StaffLeaveKind" NOT NULL,
|
||||
"status" "StaffRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE NOT NULL,
|
||||
"halfDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"minutes" INTEGER,
|
||||
"note" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"requestedById" TEXT NOT NULL,
|
||||
"reviewedById" TEXT,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_leaves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leave_days" (
|
||||
"id" TEXT NOT NULL,
|
||||
"leaveId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"vacationUnits" INTEGER NOT NULL DEFAULT 0,
|
||||
"creditMinutes" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_leave_days_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_time_entries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"startMinute" INTEGER NOT NULL,
|
||||
"endMinute" INTEGER NOT NULL,
|
||||
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"note" TEXT,
|
||||
|
||||
CONSTRAINT "staff_time_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_weeks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"weekStart" DATE NOT NULL,
|
||||
"status" "StaffWeekStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"submittedAt" TIMESTAMP(3),
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"reviewedById" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"dailySnapshot" JSONB,
|
||||
|
||||
CONSTRAINT "staff_weeks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_adjustments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_adjustments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_profiles_educatorId_key" ON "staff_profiles"("educatorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_profiles_userId_key" ON "staff_profiles"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_profiles_kitaId_idx" ON "staff_profiles"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_work_patterns_profileId_effectiveFrom_key" ON "staff_work_patterns"("profileId", "effectiveFrom");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_day_overrides_profileId_date_key" ON "staff_day_overrides"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_holidays_kitaId_date_key" ON "staff_holidays"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_leave_accounts_profileId_year_key" ON "staff_leave_accounts"("profileId", "year");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_leaves_profileId_status_startDate_idx" ON "staff_leaves"("profileId", "status", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_leave_days_leaveId_date_key" ON "staff_leave_days"("leaveId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_time_entries_profileId_date_idx" ON "staff_time_entries"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_weeks_profileId_weekStart_key" ON "staff_weeks"("profileId", "weekStart");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_adjustments_profileId_date_idx" ON "staff_adjustments"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_history_profileId_createdAt_idx" ON "staff_history"("profileId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_notifications_userId_readAt_createdAt_idx" ON "staff_notifications"("userId", "readAt", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_work_patterns" ADD CONSTRAINT "staff_work_patterns_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_day_overrides" ADD CONSTRAINT "staff_day_overrides_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_holidays" ADD CONSTRAINT "staff_holidays_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leave_accounts" ADD CONSTRAINT "staff_leave_accounts_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leaves" ADD CONSTRAINT "staff_leaves_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leave_days" ADD CONSTRAINT "staff_leave_days_leaveId_fkey" FOREIGN KEY ("leaveId") REFERENCES "staff_leaves"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_time_entries" ADD CONSTRAINT "staff_time_entries_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_weeks" ADD CONSTRAINT "staff_weeks_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_adjustments" ADD CONSTRAINT "staff_adjustments_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_history" ADD CONSTRAINT "staff_history_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,4 +1,19 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||
volumes:
|
||||
- kita_planer_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
@@ -12,7 +27,7 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
DATABASE_URL: ${DATABASE_URL:?}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-kita_planer}:${POSTGRES_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-kita_planer}?schema=public
|
||||
AUTH_SECRET: ${AUTH_SECRET:?}
|
||||
AUTH_URL: ${AUTH_URL:?}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?}
|
||||
@@ -23,19 +38,34 @@ services:
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:?}
|
||||
EMAIL_FROM: ${EMAIL_FROM:?}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
||||
NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?}
|
||||
UPLOAD_DIR: /app/uploads/announcements
|
||||
volumes:
|
||||
- kita_planer_uploads:/app/uploads
|
||||
networks:
|
||||
- default
|
||||
- coolify
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
kita_planer_uploads:
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?}
|
||||
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "True"
|
||||
PGADMIN_CONFIG_CONSOLE_LOG_LEVEL: 30
|
||||
volumes:
|
||||
- kita_planer_pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "80"
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
external: true
|
||||
volumes:
|
||||
kita_planer_postgres_data:
|
||||
kita_planer_uploads:
|
||||
kita_planer_pgadmin_data:
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
||||
|
||||
## Ergänzung vom 09.10.2026: Notdienstalarm
|
||||
|
||||
Der Notdienstalarm ist inzwischen umgesetzt. Admins und Koordination alarmieren die eingeteilte Familie für einen ganzen Tag oder ein Zeitfenster. Persönliche Rückmeldelinks erzeugen eine gemeinsame Haushaltsantwort; Dashboard-Hinweise, eine 30-Minuten-Frist, Klärungsbedarf, verspätete Freigabe, telefonische Korrektur, Stornierung, Versandstatus und Hintergrundprüfung sind angebunden. Aktive Alarme sperren Planumzuordnung und Diensttausche. Siehe [NOTDIENSTALARM.md](NOTDIENSTALARM.md) für Bedienung und benötigte Hosting-Konfiguration.
|
||||
|
||||
Die folgende ursprüngliche Bestandsaufnahme bleibt als historischer Stand erhalten; darin als offen bezeichnete Alarmfunktionen sind durch diese Ergänzung überholt.
|
||||
|
||||
Stand: **07.10.2026**
|
||||
|
||||
Geprüfter Ausgangsbranch: `codex/notdienst-swaps`
|
||||
|
||||
Geprüfter Commit: `ec3f4f933d42068c9d8446dc95de9815d5779238`
|
||||
|
||||
Dokumentationsbranch: `codex/funktionsinventar`
|
||||
|
||||
**Ergänzung vom 07.10.2026:** Auf `codex/personalbereich` wurde anschließend der Personalbereich für Arbeitszeit, Urlaub und Freizeitausgleich umgesetzt. Diese Bestandsaufnahme bleibt als Beschreibung des Ausgangsstands erhalten; der neue Umfang, Berechnungsregeln, Einrichtung und aktuelle Prüfungen stehen in [PERSONALBEREICH.md](PERSONALBEREICH.md). Die Aussagen unten zu fehlender Zeiterfassung, Accountverknüpfung und Tests beziehen sich auf den Ausgangscommit.
|
||||
|
||||
**Ergänzung vom 08.10.2026:** Der Personalbereich enthält jetzt auch Aktivierungsmails, Antrags- und Entscheidungshinweise sowie eine Abschalteinstellung im Fachkraftprofil. Der aktuelle Umfang und die Versandkonfiguration stehen ebenfalls in [PERSONALBEREICH.md](PERSONALBEREICH.md).
|
||||
|
||||
## 1. Grundlage der Prüfung
|
||||
|
||||
Diese Bestandsaufnahme beschreibt die im Repository vorhandenen Produktfunktionen. Geprüft wurden Seiten und Navigation, Formulare und Dialoge, Server Actions, API-Routen, Datenmodell, E-Mail-Anbindung und Betriebsdateien. Die Planung in `planung.md`, die UI-Liste in `UI-TODO.md` und der frühere Live-Testplan wurden mit dem aktuellen Code abgeglichen.
|
||||
|
||||
**„Vorhanden“ bedeutet hier: Oberfläche und zugehörige Verarbeitung sind im Code angebunden. Es bedeutet keine Bestätigung durch einen aktuellen Live-Test.** Es wurden keine Produktionsdaten verändert, keine E-Mails verschickt und keine Datenbankänderungen ausgeführt. Die tatsächliche Zustellung von E-Mails, das Verhalten im Browser und die produktive Konfiguration bleiben gesondert zu prüfen.
|
||||
|
||||
Status:
|
||||
|
||||
| Kennzeichnung | Bedeutung |
|
||||
| --- | --- |
|
||||
| Vorhanden | Im aktuellen Code als erreichbarer Ablauf umgesetzt. |
|
||||
| Teilweise | Funktion vorhanden, aber mit konkreten Einschränkungen oder fehlender Anbindung. |
|
||||
| Vorbereitet | Datenmodell, Komponente oder Aktion vorhanden; vollständiger Nutzerablauf fehlt. |
|
||||
| Offen | In der Planung vorgesehen, im geprüften Code nicht umgesetzt. |
|
||||
|
||||
## 2. Gesamtüberblick
|
||||
|
||||
Die Anwendung besitzt bereits eine breite MVP-Grundlage für mehrere unabhängige Kitas: Registrierung, Einrichtung, Familien und Kinder, Notdienst-Verfügbarkeiten und Planung, Diensttausch, Kalender, Mitbringlisten, Abwesenheiten, Kontaktdaten und Superadmin-Support. Einige beworbene Abläufe sind jedoch noch nicht vollständig nutzbar, insbesondere Alarmierung, Eltern-Rückmeldungen zu Events und die Anzeige des Schwarzen Bretts für Eltern.
|
||||
|
||||
| Bereich | Stand | Wichtigste Einschränkung |
|
||||
| --- | --- | --- |
|
||||
| Öffentliche Website und Kontakt | Vorhanden | Impressum und Datenschutz enthalten Platzhalter. |
|
||||
| Registrierung, Login, Kita-Einrichtung | Vorhanden | Kein selbstständiger Passwort-Reset; Modulschalter wirken nur teilweise. |
|
||||
| Dashboard | Vorhanden | News-, RSVP- und Alarm-Komponenten sind nicht eingebunden. |
|
||||
| Profil und eigene Kinder | Vorhanden | Persönlicher Name, E-Mail und Rolle sind im Profil nur lesbar. |
|
||||
| Familienverwaltung und feste Ämter | Vorhanden | Kein vollständiger Einladungs-Support mit erneutem Versand/Widerruf. |
|
||||
| Notdienst-Verfügbarkeiten | Vorhanden | Wochenenden und Schließtage werden nur in der Oberfläche ausgeschlossen. |
|
||||
| Notdienst-Planung | Vorhanden | Keine automatische Erinnerung und keine E-Mail bei Erstveröffentlichung. |
|
||||
| Notdienst-Tausch | Vorhanden | Nur Elternrolle kann neue Tauschanfragen starten. |
|
||||
| Notdienst-Alarmierung | Vorbereitet | Auslösebutton ist auf keiner Seite eingebunden. |
|
||||
| Kalender und Mitbringlisten | Vorhanden | Keine Kollisionsprüfung für Raumbuchungen; Mitbringsel nicht direkt bearbeitbar. |
|
||||
| Event-Teilnahme/RSVP | Teilweise | Verwaltung vorhanden, Eltern-Eingabe nicht in eine Seite eingebunden. |
|
||||
| Abwesenheiten | Vorhanden | Gruppenfilter verwendet eine künstliche Zuordnung. |
|
||||
| Schwarzes Brett | Teilweise | Veröffentlichung vorhanden, Elternanzeige und Lesestatus fehlen im aktuellen Dashboard. |
|
||||
| Adressbuch | Vorhanden | Opt-in gilt für Kontaktdaten; weitere Haushaltsdaten bleiben sichtbar. |
|
||||
| ErzieherInnen-Stammdaten | Vorhanden | Stammdatensatz und Benutzerrolle `ERZIEHER` sind getrennte Konzepte. |
|
||||
| Superadmin-Verwaltung | Vorhanden | Einladungszähler verwenden ein anderes Modell als der reale Einladungsablauf. |
|
||||
| Betrieb und Qualität | Teilweise | TypeScript erfolgreich, ESLint mit Fehlern; keine automatisierte Testsuite gefunden. |
|
||||
|
||||
## 3. Rollen und Zugriffsbereiche
|
||||
|
||||
| Rolle | Aktuelle Möglichkeiten |
|
||||
| --- | --- |
|
||||
| `SUPERADMIN` | Systemweite Kita-Übersicht, Kita-Diagnosen, Support-Aktionen und Audit-Protokoll. Standardmäßig ohne Kita-Zuordnung und mit eigener Oberfläche unter `/admin`. |
|
||||
| `ADMIN` | Verwaltung der eigenen Kita: Familien, Ämter, ErzieherInnen, Dienste, Meldungen, Kalender und Notdienst. Mit eigenem Haushalt auch Elternfunktionen nutzbar. |
|
||||
| `KOORDINATOR` | Notdienstplanung, Kalenderverwaltung, Event-Anmeldungen und Abwesenheitsübersicht. Mit eigenem Haushalt auch Verfügbarkeiten eintragbar. |
|
||||
| `ERZIEHER` | Dashboard, Kalender, Adressbuch, Profil und Abwesenheitsübersicht. Kein Zugriff auf die Notdienst-Eingabeseite. |
|
||||
| `ELTERN` | Eigener Haushalt/Kinder, Verfügbarkeiten, Kalender, Terminanfragen, Mitbringsel, Diensttausch, Abwesenheitsmeldungen und Adressbuch. |
|
||||
|
||||
Navigation und serverseitige Rollenprüfungen sind vorhanden. Die Rolle `ERZIEHER` existiert im Datenmodell und wird in Berechtigungen verwendet. Die ErzieherInnen-Verwaltung erstellt jedoch nur Namen als Stammdaten und keine Benutzerkonten. Ein regulärer Dialog zur Einladung von Erzieher-Accounts oder zur Vergabe von Admin-/Koordinatorrollen wurde nicht gefunden.
|
||||
|
||||
Quellen: [Navigation](../src/components/dashboard/sidebar-nav.tsx), [Zugriffsprüfungen](../src/lib/auth-utils.ts), [Datenmodell](../prisma/schema.prisma).
|
||||
|
||||
## 4. Funktionsinventar nach Bereich
|
||||
|
||||
### 4.1 Öffentliche Website und Kontakt
|
||||
|
||||
- Startseite mit Produktbeschreibung, Funktionsvorschauen, Rollenbeschreibung, Einstieg in Registrierung und Login sowie Kontaktbereich.
|
||||
- Fünf öffentliche Detailseiten: Notdienst-Planer, Kita-Kalender, Eltern-Adressbuch, Krankmeldung und Schwarzes Brett.
|
||||
- Kontaktformular für allgemeine Fragen, Demo-Anfragen und individuelles Setup.
|
||||
- Eingaben: Name, optionale Kita, E-Mail-Adresse, Anfrageart und Nachricht.
|
||||
- Versand einer Anfrage an den konfigurierten Kontakt-Empfänger und einer Bestätigung an die anfragende Person; passende Antwortadressen werden gesetzt.
|
||||
- Bot-Schutz durch ein unsichtbares Zusatzfeld und ein Limit von fünf Kontaktanfragen je IP-Adresse pro Stunde. Das Limit liegt im Arbeitsspeicher des jeweiligen Prozesses.
|
||||
- Sitemap, Robots-Datei und Metadaten für öffentliche Seiten.
|
||||
- Optionales Umami-Tracking über Umgebungsvariablen, auf eine Liste öffentlicher Seiten beschränkt. Suchparameter und URL-Fragmente werden ausgeschlossen; Do-Not-Track wird berücksichtigt.
|
||||
- Impressum und Datenschutzerklärung sind als Seiten vorhanden, enthalten aber noch Betreiber-, Hosting- und weitere Platzhalter.
|
||||
|
||||
Quellen: [Startseite](../src/app/page.tsx), [Feature-Inhalte](../src/lib/marketing/features.ts), [Kontaktaktion](../src/actions/contact.ts), [Analytics](../src/components/analytics/umami-analytics.tsx), [Impressum](../src/app/impressum/page.tsx), [Datenschutz](../src/app/datenschutz/page.tsx).
|
||||
|
||||
### 4.2 Registrierung, Anmeldung und Einrichtung
|
||||
|
||||
- Gründerregistrierung mit Vorname, Nachname, E-Mail-Adresse, Passwort und verpflichtender Datenschutzannahme.
|
||||
- Prüfung auf bereits verwendete E-Mail-Adressen und Passwort-Hashing mit bcrypt.
|
||||
- Gründer erhält zunächst die Adminrolle ohne Kita-Zuordnung und wird direkt angemeldet.
|
||||
- Kita-Einrichtung mit Name, eindeutigem Slug, Aktivierung von Notdienst/Kalender/Adressbuch und Mindestzahl an Verfügbarkeiten pro Kind.
|
||||
- Bei aktivem Notdienstmodul ist mindestens ein Verfügbarkeitstag erforderlich; die Einrichtung erlaubt höchstens 31.
|
||||
- Kita-Anlage und Zuordnung des Gründers erfolgen in einer Datenbanktransaktion; erneute Einrichtung desselben Kontos wird abgefangen.
|
||||
- Login mit E-Mail und Passwort, Rückleitung zu einer internen Zielseite und allgemeine Fehlermeldung bei falschen Zugangsdaten.
|
||||
- Abmeldung über Desktop-Navigation und mobiles Menü.
|
||||
- Nach zehn Fehlversuchen wird das betreffende Konto für 15 Minuten gesperrt; erfolgreicher Login setzt den Zähler zurück.
|
||||
- JWT-basierte Sessions mit Benutzer-, Kita-, Familien- und Rolleninformationen. Rolle und Zuordnungen werden bei weiteren Session-Aufrufen mit der Datenbank abgeglichen.
|
||||
- Geschützte Seiten leiten nicht angemeldete Personen zum Login und Gründer ohne Kita zur Einrichtung weiter.
|
||||
- Datenschutzannahme wird mit Zeitpunkt und Versionskennung gespeichert. Die Sonderseite für fehlende Zustimmung verweist auf Support; sie enthält kein Formular zum Nachholen der Zustimmung.
|
||||
|
||||
**Einschränkungen:** Kein Passwort-vergessen-/Passwort-ändern-Ablauf. Die Gründerregistrierung setzt `emailVerifiedAt` unmittelbar, ohne Bestätigungsmail. Ein allgemeines IP-Limit für Login oder Registrierung ist nicht angebunden; das Kontaktformular hat sein eigenes Limit.
|
||||
|
||||
Quellen: [Registrierung](../src/app/register/actions.ts), [Einrichtung](../src/app/onboarding/actions.ts), [Login](../src/app/login/actions.ts), [Authentifizierung](../src/auth.ts), [Proxy](../src/proxy.ts), [Consent-Seite](../src/app/onboarding/consent/page.tsx).
|
||||
|
||||
### 4.3 Dashboard und Navigation
|
||||
|
||||
- Begrüßung mit Kita-Namen, Benutzernamen und aktuellem Datum.
|
||||
- Kennzahlen zu Familien, aktiven Kindern, eingetragenen Notdienst-Verfügbarkeiten des Folgemonats und rechnerisch heute anwesenden Kindern.
|
||||
- Direktes Melden einer Kinderabwesenheit und Übersicht der laufenden/bevorstehenden eigenen Abwesenheiten.
|
||||
- Schnellzugriffe auf Familienverwaltung, Kalender, Notdienstplanung und Adressbuch. Die drei letztgenannten berücksichtigen die Modulschalter bei der Anzeige.
|
||||
- Hinweisdialog bei unvollständigem Profil: Telefon, Adresse sowie gegebenenfalls Haushalt und Kinder. Fortschritt wird angezeigt; der Hinweis kann im lokalen Browser dauerhaft ausgeblendet werden.
|
||||
- Rollenabhängige Seitenleiste und ein auf kleinen Bildschirmen aufklappbares Menü.
|
||||
|
||||
**Einschränkungen:** Die Notdienstkennzahl zählt Verfügbarkeitseinträge, keine tatsächlich besetzten Plantage. Schnellzugriffe auf Verwaltung werden nicht vollständig nach Rolle ausgeblendet; die Zielseiten besitzen Rollenprüfungen. Die Notdienst-Badge `4` in der Seitenleiste ist fest hinterlegt.
|
||||
|
||||
Quellen: [Dashboard](../src/app/dashboard/page.tsx), [Profilhinweis](../src/components/OnboardingDialog.tsx), [Dashboard-Rahmen](../src/components/dashboard/dashboard-shell.tsx).
|
||||
|
||||
### 4.4 Profil, Haushalt und eigene Kinder
|
||||
|
||||
- Anzeige von Name, E-Mail-Adresse, Rolle, Kita, Haushalt, angelegtem Konto und zugewiesenen festen Ämtern.
|
||||
- Bearbeiten von Telefonnummer, Straße, Postleitzahl und Ort.
|
||||
- Adressbuchfreigabe erteilen oder zurückziehen; Zeitpunkt der Freigabe wird gespeichert.
|
||||
- Eigenen Haushalt anlegen, wenn noch keiner zugeordnet ist.
|
||||
- Eigene Kinder anlegen, bearbeiten und löschen; Vor-/Nachname sowie optionales Geburtsdatum.
|
||||
- Bei Eingabe eines Geburtsdatums: gültiges Datum, nicht in der Zukunft, höchstens sieben Jahre zurück.
|
||||
- Datenänderungen an Kindern werden auf den eigenen Haushalt und die eigene Kita begrenzt.
|
||||
- Eigenes Konto nach Bestätigung löschen. Bestehen aktuelle oder zukünftige veröffentlichte Notdienstpflichten des Haushalts, wird die Selbstlöschung abgelehnt.
|
||||
- Beim letzten Benutzer eines Haushalts wird die Familie samt abhängigen Kindern gelöscht; bei weiteren Benutzern bleibt der Haushalt bestehen.
|
||||
|
||||
**Einschränkungen:** Name, E-Mail und Rolle lassen sich im persönlichen Profil nicht ändern. Eine Sperre der Kinderlöschung bei bestehenden Notdienstpflichten ist in `deleteMyChild` nicht vorhanden, obwohl die Kontolöschung diese Pflichten prüft.
|
||||
|
||||
Quellen: [Profilseite](../src/app/dashboard/profil/page.tsx), [Profilaktionen](../src/app/dashboard/profil/actions.ts).
|
||||
|
||||
### 4.5 Familienverwaltung, Eltern-Einladungen und feste Ämter
|
||||
|
||||
- Adminübersicht aller Familien mit Eltern und Kindern; Suche nach Familie, Eltern, E-Mail und Kindern.
|
||||
- Umschalten zwischen Tabelle und Karten.
|
||||
- Neue Familie mit einem oder zwei Elternteilen sowie einem bis zehn Kindern anlegen.
|
||||
- Familienname, Namen/E-Mail-Adressen der Eltern und Kindernamen bearbeiten.
|
||||
- Zweiten Elternteil ergänzen, neue Kinder hinzufügen und Kinder entfernen. Der Bearbeitungsablauf verlangt mindestens ein verbleibendes Kind und höchstens zwei Elternteile.
|
||||
- Neue Elternkonten erhalten die Rolle `ELTERN` und einen Einladungslink zur Passwortvergabe mit sieben Tagen Gültigkeit.
|
||||
- Aktivierung prüft Link, Ablaufdatum, Passwortlänge, Passwortbestätigung und Datenschutzannahme; optionales Adressbuch-Opt-in.
|
||||
- Nach Aktivierung werden Passwort und Zustimmungen gespeichert, der Token gelöscht und die Person direkt angemeldet.
|
||||
- Die Familienliste zeigt anhand der Kontoverifikation, ob eine Einladung noch offen ist.
|
||||
- Feste Ämter mit Name und optionaler Beschreibung anlegen und löschen.
|
||||
- Ein oder mehrere Ämter an einzelne Benutzer vergeben und Zuordnungen entfernen; doppelte identische Zuordnungen werden verhindert.
|
||||
- Ämter sind im Profil und Adressbuch sichtbar.
|
||||
|
||||
**Einschränkungen:** Kein regulärer Dialog zum erneuten Versand oder Widerruf einer Einladung. Einladungen werden im tatsächlichen Ablauf in `VerificationToken` gespeichert. Das separate Modell `Invitation` ist dafür nicht angebunden. Scheitert der Mailversand nach der Datenanlage, bleibt die Familie angelegt und eine Fehlermeldung wird zurückgegeben. Eine vollständige Familienlöschung durch Kita-Admins ist nicht angeboten.
|
||||
|
||||
Quellen: [Familienaktionen](../src/app/dashboard/families/actions.ts), [Familienliste](../src/app/dashboard/families/_components/families-list-client.tsx), [Einladungsaktivierung](../src/app/invite/%5Btoken%5D/actions.ts), [Ämter](../src/app/dashboard/families/duty-actions.ts).
|
||||
|
||||
### 4.6 Notdienst-Verfügbarkeiten für Eltern
|
||||
|
||||
- Eingabe für den Folgemonat, bezogen auf aktive Kinder des eigenen Haushalts.
|
||||
- Mindestzahl an ausgewählten Tagen entspricht Anzahl der Kinder × Kita-Einstellung.
|
||||
- Bereits gespeicherte Haushaltseinträge werden geladen und können erneut gespeichert werden.
|
||||
- Monatskalender mit Terminen, Kategorien, Veranstaltungsdetails und auswählbaren Verfügbarkeitstagen.
|
||||
- Die Oberfläche lässt Montag bis Freitag zu und sperrt bestätigte Schließtage.
|
||||
- Auswahlfortschritt und Rückmeldung bei zu wenigen Tagen.
|
||||
- Die ausgewählten verschiedenen Tage werden beim Speichern reihum auf die angegebenen Kinder verteilt; bestehende Einträge dieser Kinder im Zielmonat werden ersetzt.
|
||||
- Eingabefrist ist im Code fest: bis einschließlich 26. des aktuellen Monats, gesperrt ab dem 27.
|
||||
- Ohne aktive eigene Kinder erscheint ein Hinweis mit Verweis zum Profil.
|
||||
|
||||
**Einschränkungen:** Die Server Action prüft Datumsformat, Zielmonat, Mindestzahl und Haushaltszugehörigkeit, aber keine Wochenenden oder Schließtage. Die Frist ist nicht im Einstellungsdialog konfigurierbar. Abwesenheitsmeldungen werden bei den Verfügbarkeiten nicht automatisch berücksichtigt.
|
||||
|
||||
Quellen: [Elternseite](../src/app/dashboard/notdienst/page.tsx), [Kalendereingabe](../src/app/dashboard/notdienst/notdienst-form.tsx), [Speichern](../src/app/dashboard/notdienst/actions.ts), [Fristregel](../src/lib/date-utils.ts).
|
||||
|
||||
### 4.7 Notdienst-Planungszentrale
|
||||
|
||||
- Admins und Koordinatoren können über Monat/Jahr navigieren; Vorgabe ist der Folgemonat.
|
||||
- Übersicht von Haushalten, Kindern, Verfügbarkeitstagen, Haushalten mit/ohne Rückmeldung und Planstatus.
|
||||
- Automatische Erzeugung eines monatlichen Entwurfs; ein bestehender Monatsplan verhindert erneute Generierung.
|
||||
- Für jeden Montag bis Freitag wird aus verfügbaren Kindern ein Haushalt ausgewählt, der im bisherigen Monatsdurchlauf möglichst selten eingeteilt wurde. Bei Gleichstand kommt Zufall zum Einsatz; der Zähler startet jeden Monat neu.
|
||||
- Tage ohne Verfügbarkeiten bleiben unbesetzt.
|
||||
- Vorschau und manuelle Zuordnung zu einem aktiven Kind der Kita; Tage können auch geleert werden.
|
||||
- Entwurf veröffentlichen; veröffentlichte Einteilungen erscheinen im Kalender.
|
||||
- Veröffentlichte Pläne nachbearbeiten, solange der betreffende Tag nicht vergangen ist.
|
||||
- Nach manuellen Änderungen eines veröffentlichten Plans werden E-Mails an betroffene Elternkonten der Rolle `ELTERN` angestoßen; betroffene offene Tauschanfragen werden ungültig.
|
||||
- Entwurfsplan mit Bestätigungsdialog zurücksetzen und löschen. Veröffentlichte Pläne werden serverseitig vom Reset ausgeschlossen.
|
||||
- Kita-Einstellungen für Mindestverfügbarkeiten und Erinnerungs-Vorlauf speichern.
|
||||
- Manuell Erinnerungen an Haushalte versenden, die im ausgewählten Monat gar keinen Verfügbarkeitseintrag haben.
|
||||
- „Als PDF exportieren“ öffnet den Browser-Druckdialog (`window.print()`); die PDF-Datei entsteht über die Druckfunktion des Browsers.
|
||||
|
||||
**Einschränkungen:** Keine garantierte optimale oder über mehrere Monate ausgeglichene Verteilung. Die Generierung berücksichtigt keine Feiertage, Schließtage, Teamtage oder Kinderabwesenheiten. Ein Erinnerungs-Vorlauf wird gespeichert, aber kein zeitgesteuerter Job verwendet ihn. Haushalte mit zu wenigen Einträgen erhalten über diese Erinnerungsaktion keine Erinnerung, sobald wenigstens ein Eintrag vorliegt. Beim erstmaligen Veröffentlichen wird keine Zuteilungsmail versendet; die entsprechende E-Mail-Vorlage allein ist kein angebundener Versand.
|
||||
|
||||
Quellen: [Planungsseite](../src/app/dashboard/notdienst/plan/page.tsx), [Planaktionen](../src/app/dashboard/notdienst/plan/actions.ts), [Planoberfläche](../src/app/dashboard/notdienst/plan/plan-view.tsx).
|
||||
|
||||
### 4.8 Notdienst-Tausch zwischen Familien
|
||||
|
||||
- Eltern können im Kalender einen fremden Notdienst anfragen und einen eigenen veröffentlichten Dienst zum Tausch anbieten; optionale Nachricht bis 500 Zeichen.
|
||||
- Beide Dienste müssen derselben Kita angehören, veröffentlicht und heute oder zukünftig sein. Ein Tausch im eigenen Haushalt wird abgewiesen.
|
||||
- Dienste mit wartender oder bestätigter Alarmierung sind vom Tausch ausgeschlossen.
|
||||
- Bereits beteiligte Dienste mit offenen Tauschanfragen können keine weitere offene Anfrage erhalten.
|
||||
- Der angefragte Haushalt erhält eine E-Mail und sieht die offene Anfrage im Kalender.
|
||||
- Angefragter Haushalt kann annehmen oder ablehnen; anfragender Haushalt kann zurückziehen.
|
||||
- Annahme vertauscht beide Kinder-Zuordnungen in einer Transaktion, speichert die Entscheidung und storniert weitere betroffene offene Anfragen.
|
||||
- Annahme und Ablehnung lösen Benachrichtigungen an beide Familien aus; Rücknahme speichert den Status ohne eigenen Mailversand.
|
||||
- Kalender und Notdienstansichten werden nach Änderungen neu validiert.
|
||||
|
||||
**Einschränkungen:** Neue Anfragen sind auf die exakte Rolle `ELTERN` beschränkt; Eltern mit Admin-/Koordinatorrolle können sie nicht starten. Die Oberfläche zeigt offene Anfragen, keine vollständige abgeschlossene Tauschhistorie. Eine erneute Verfügbarkeitsprüfung für die getauschten Tage erfolgt nicht.
|
||||
|
||||
Quellen: [Tauschaktionen](../src/app/dashboard/kalender/actions.ts), [Kalender und Anfragen](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Tauschdialog](../src/app/dashboard/kalender/_components/termin-list.tsx).
|
||||
|
||||
### 4.9 Notdienst-Alarmierung
|
||||
|
||||
Die am 09.10.2026 abgestimmte Erweiterung ist in [NOTDIENSTALARM_PLAN.md](NOTDIENSTALARM_PLAN.md) beschrieben. Sie ist zunächst geplant; der folgende Abschnitt beschreibt den vorhandenen Code.
|
||||
|
||||
Vorbereitet sind:
|
||||
|
||||
- Server Action zum Auslösen eines Alarms durch Admin/Koordinator für eine Einteilung.
|
||||
- Kita-Prüfung und Abweisung einer bereits alarmierten Einteilung.
|
||||
- Eigene Alarmdatensätze und E-Mails für die Elternkonten der eingeteilten Familie mit Rolle `ELTERN`.
|
||||
- Bestätigungsseite unter `/alert/[token]`; der Link erlaubt die Bestätigung ohne Login.
|
||||
- Statuswechsel von `PENDING` auf `CONFIRMED` mit Zeitstempel; erneute Bestätigung wird abgewiesen.
|
||||
- Vorbereitete Button-Komponente für das Auslösen.
|
||||
|
||||
**Aktueller Stand:** `AlertButton` wird von keiner Seite eingebunden. Ein erreichbarer Auslöseablauf und eine laufende Statusübersicht für die Koordination fehlen damit in der aktuellen Oberfläche. Das Datenmodell kann eine erkrankte Fachkraft und Notizen speichern, aber die Aktion nimmt nur die Einteilungs-ID entgegen. Eine Auswahl der Fachkraft, Alarmstornierung, Eskalation oder zeitliche Token-Gültigkeit ist nicht angebunden.
|
||||
|
||||
Quellen: [Alarmaktion](../src/app/dashboard/actions.ts), [ungebundener Alarmbutton](../src/app/dashboard/alert-button.tsx), [Bestätigung](../src/app/alert/%5Btoken%5D/actions.ts).
|
||||
|
||||
### 4.10 Kalender, Terminanfragen und Mitbringlisten
|
||||
|
||||
- Gemeinsamer Kalender für bestätigte Termine, eigene ausstehende Terminanfragen, geplante Elterndienste und veröffentlichte Notdienste.
|
||||
- Listen-, Monats- und Agendaansicht; Monatsnavigation und Sprung zu heute.
|
||||
- Tagesdialog mit Termindetails, Diensten und Mitbringlisten.
|
||||
- Suche nach Titel/Beschreibung und bei Diensten nach Familie beziehungsweise Kind.
|
||||
- Kategorienfilter und Umschaltung zwischen eigenen und allen Diensten; reguläre Kita-Termine bleiben auch im eigenen Filter sichtbar.
|
||||
- Kategorien: Kita-Fest, Schließtag, Teamtag, Mitmach-Tag, Elternabend, Mitgliederversammlung, Elterncafé, interner/externer Geburtstag und Sonstiges.
|
||||
- Terminanfrage mit Titel, Beschreibung, Kategorie, Anfang/Ende und Ganztagsangabe.
|
||||
- Eigene Anfragen werden als ausstehend angezeigt; Admins/Koordinatoren können ausstehende Anfragen bestätigen oder mit Begründung ablehnen.
|
||||
- Admins/Koordinatoren können bestätigte Termine direkt anlegen, bearbeiten und endgültig löschen.
|
||||
- Datumsprüfung: Beginn nicht in der Vergangenheit, Ende nicht vor Beginn.
|
||||
- Mitbringliste je Termin aktivieren/deaktivieren; Textbeiträge hinzufügen.
|
||||
- Eigene Beiträge löschen; Admins/Koordinatoren können auch fremde Beiträge löschen.
|
||||
- Verweise aus Dienst-/Notdienstterminen in die entsprechenden Verwaltungsseiten; Diensttausch über den Kalender.
|
||||
|
||||
**Einschränkungen:** Ausstehende Anfragen blockieren keinen Zeitraum durch eine serverseitige Kollisionsprüfung. Eine Anfrage ist für andere Eltern nicht allgemein als reservierter Slot sichtbar. Mitbringbeiträge lassen sich nicht direkt bearbeiten, sondern nur löschen und neu anlegen. Es wurde kein Kalenderabo, ICS-Export oder Serientermin-Ablauf gefunden.
|
||||
|
||||
Quellen: [Kalenderseite](../src/app/dashboard/kalender/page.tsx), [Kalenderansichten](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Terminaktionen](../src/app/dashboard/kalender/actions.ts), [Mitbringliste](../src/app/dashboard/kalender/_components/mitbringsel-list.tsx).
|
||||
|
||||
### 4.11 Event-Anmeldungen und RSVP
|
||||
|
||||
- Admins/Koordinatoren können Termine mit Teilnahmeabfrage, optionaler Rückmeldefrist und Hinweisen für Teilnehmende anlegen/bearbeiten.
|
||||
- Verwaltungsseite listet Termine samt Status und zeigt pro Event Zusagen, Absagen und ausstehende Antworten.
|
||||
- Kindbezogene Teilnehmerliste über alle aktiven Kinder der Kita.
|
||||
- Server Action für Zu-/Absage eines eigenen aktiven Kindes, inklusive Prüfung von Kita, Haushalt, bestätigtem Termin und Frist.
|
||||
- Fehlt eine eigene Frist, gilt der Eventbeginn als Rückmeldeschluss. Eine bestehende Antwort kann bis dahin überschrieben werden.
|
||||
- Teilnehmerhinweise werden im Kalender bei Admin/Koordinator und bei einem Haushalt mit mindestens einer Zusage angezeigt.
|
||||
|
||||
**Aktueller Stand:** Die Eltern-Eingabekomponente `RsvpActionCard` existiert, wird aber von keiner Seite verwendet. Eltern besitzen deshalb im aktuellen Seitenaufbau keinen eingebundenen Zu-/Absagedialog. Außerdem wird `participantInfo` bereits an den Kalender-Client übertragen und nur bei der Darstellung ausgeblendet; die Anzeigeprüfung ist keine serverseitige Zugriffsbeschränkung für diese Information.
|
||||
|
||||
Quellen: [RSVP-Aktionen](../src/actions/rsvp.ts), [Event-Verwaltung](../src/app/dashboard/admin/kalender/admin-calendar-manager.tsx), [ungebundene Elternkomponente](../src/app/dashboard/rsvp-action-card.tsx), [Kalenderdaten](../src/app/dashboard/kalender/page.tsx).
|
||||
|
||||
### 4.12 Krank- und Abwesenheitsmeldungen
|
||||
|
||||
- Eigenes aktives Kind aus dem Haushalt auswählen und für einen Tag oder Zeitraum abmelden.
|
||||
- Gründe: Krankheit, Urlaub und Sonstiges; optionale Notiz bis 500 Zeichen.
|
||||
- Ende darf nicht vor dem Beginn liegen; sich überschneidende Meldungen desselben Kindes werden abgewiesen.
|
||||
- Eigene laufende/bevorstehende Meldungen anzeigen und löschen.
|
||||
- Tagesübersicht für Admin, Koordinator und Erzieherrolle mit Kind, Familie, Grund, Zeitraum und Notiz.
|
||||
- Tagesnavigation zurück/vor/heute und freie Datumswahl.
|
||||
- Grundfilter, Kennzahlen und Hinweise zu Wochenende, Schließtag/Teamtag sowie Tagen mehr als 30 Tage in der Zukunft.
|
||||
- Abwesenheiten sind nicht als echte Anwesenheitserfassung mit Check-in/Check-out implementiert; „anwesend“ ist aus Kinderzahl minus Meldungen abgeleitet.
|
||||
|
||||
**Einschränkungen:** Gruppen „Krippe“ und „Elementar“ werden aus dem ersten Zeichen des Kindernamens künstlich abgeleitet (`mockGroup`). Das Datenmodell hat keine echte Gruppenzuordnung. Die Anwesenheitszahl dieser Ansicht verwendet die gefilterten Fehlmeldungen bei weiterhin gesamter Kinderzahl und kann deshalb bei aktivem Filter irreführend sein. Eine bestehende Abwesenheit kann nur gelöscht und neu gemeldet, nicht direkt bearbeitet werden.
|
||||
|
||||
Quellen: [Abwesenheitsaktionen](../src/actions/absences.ts), [Elternformular](../src/app/dashboard/absence-card.tsx), [Tagesübersicht](../src/app/dashboard/admin/abwesenheiten/page.tsx), [Filter](../src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx).
|
||||
|
||||
### 4.13 Wöchentlicher Dienstplan
|
||||
|
||||
- Separater Dienstplan für Haushalte, unabhängig von festen personenbezogenen Ämtern und Notdienst.
|
||||
- Diensttypen mit Name/Beschreibung anlegen und löschen.
|
||||
- Wochenraster mit wählbarem Beginn und Zeitraum; Standard zwölf Wochen, maximal 104 Wochen.
|
||||
- Haushalt je Diensttyp/Kalenderwoche auswählen oder Zuordnung entfernen; Änderungen werden direkt gespeichert.
|
||||
- Kita-Zugehörigkeit von Diensttyp und Familie wird geprüft.
|
||||
- Einteilungen erscheinen im gemeinsamen Kalender.
|
||||
- Manuelle Erinnerungsaktion für noch nicht erinnerte geplante Dienste, deren Beginn in der aktuellen Woche liegt.
|
||||
- Versand an verifizierte Benutzer der Familie; erfolgreicher Versand wird mit `reminderSentAt` gespeichert. Erneute Zuordnung setzt diesen Wert zurück.
|
||||
|
||||
**Einschränkungen:** Kein automatisch laufender Erinnerungsjob. Status `DONE` und `CANCELLED` existieren im Datenmodell, aber ein regulärer Abschluss-/Stornoablauf ist nicht eingebunden. Eine Änderung setzt den Dienst wieder auf `PLANNED`. Das Löschen eines Diensttyps löscht durch die Datenrelationen auch dessen Einteilungen.
|
||||
|
||||
Quellen: [Dienstplanseite](../src/app/dashboard/admin/dienste/page.tsx), [Raster](../src/app/dashboard/admin/dienste/duty-planner.tsx), [Dienstaktionen](../src/app/dashboard/admin/dienste/actions.ts).
|
||||
|
||||
### 4.14 Schwarzes Brett, E-Mail und Anhänge
|
||||
|
||||
- Admins können eine Ankündigung mit Titel und Markdown-Inhalt erstellen; Vorschau ist im Formular vorhanden.
|
||||
- PDF-, JPG- und PNG-Anhänge bis zehn MB je Datei.
|
||||
- Speicherung der Anhangdateien auf dem Server und der Metadaten in der Datenbank.
|
||||
- Abruf über geschützte API mit Prüfung auf angemeldeten Benutzer und dieselbe Kita; keine Auslieferung über ein öffentliches Uploadverzeichnis.
|
||||
- Optionaler E-Mail-Versand an verifizierte Haushaltskonten, Erzieherkonten oder beide Gruppen. Die Auswahl betrifft E-Mail-Empfänger und wird nicht als Sichtbarkeitsgruppe der Meldung gespeichert.
|
||||
- Adminseite zeigt die letzten acht veröffentlichten Meldungen mit Autor, Zeitpunkt und Anhanganzahl.
|
||||
- Bei Dateischreibfehlern werden die neue Meldung und angefangene Dateien zurückgenommen; bei Mailfehlern bleibt die Meldung gespeichert und eine Warnung wird zurückgegeben.
|
||||
- Lesestatusmodell und Aktion zum Markieren als gelesen sind vorhanden; `NewsTicker` enthält einen vorbereiteten Meldungsdialog.
|
||||
|
||||
**Aktueller Stand:** `NewsTicker` ist nicht in das Dashboard oder eine andere Seite eingebunden. Eltern können neue Meldungen daher derzeit über den optionalen Mailversand erhalten, besitzen aber keine eingebundene Meldungsliste in der App. Eine Adminauswertung der Lesebestätigungen sowie Bearbeiten/Löschen von Meldungen sind nicht umgesetzt.
|
||||
|
||||
Quellen: [Meldungsaktionen](../src/actions/announcements.ts), [Adminseite](../src/app/dashboard/admin/news/page.tsx), [Formular](../src/app/dashboard/admin/news/news-form.tsx), [ungebundener Ticker](../src/app/dashboard/news-ticker.tsx), [Anhang-API](../src/app/api/announcements/attachments/%5Bid%5D/route.ts).
|
||||
|
||||
### 4.15 Adressbuch
|
||||
|
||||
- Interne Übersicht der Haushalte, bei denen mindestens ein Benutzer der Kontaktfreigabe zugestimmt hat.
|
||||
- Anzeige von Haushaltsname, Kindern, Elternnamen und festen Ämtern; alphabetische Gruppierung nach Haushaltsnamen.
|
||||
- E-Mail, Telefon und Adresse werden nur für den jeweiligen Benutzer mit Opt-in geladen.
|
||||
- Suche nach Familienname, Eltern-/Kindernamen oder sichtbarer E-Mail.
|
||||
- Filter für Telefonnummer, Ämter und Haushalte mit Opt-in aller zugeordneten Benutzer.
|
||||
- Detaildialog je Familie; E-Mail- und Telefonlinks starten den jeweiligen Mail-/Telefonhandler.
|
||||
- Sichtbarkeitsdialog erklärt die Freigabe und führt zum eigenen Profil.
|
||||
- Optionales Geburtsdatum eines Kindes ist im Detaildialog sichtbar.
|
||||
|
||||
**Einschränkung:** Namen und Ämter eines zweiten Elternteils ohne Kontaktfreigabe sowie Kinderinformationen sind innerhalb eines sichtbaren Haushalts weiterhin sichtbar. Die Freigabe ist technisch kein Opt-in für sämtliche Haushaltsinformationen.
|
||||
|
||||
Quellen: [Adressbuchdaten](../src/app/dashboard/adressbuch/page.tsx), [Adressbuchoberfläche](../src/app/dashboard/adressbuch/_components/directory-client.tsx).
|
||||
|
||||
### 4.16 ErzieherInnen-Stammdaten
|
||||
|
||||
- Admins können Fachkräfte mit Vor- und Nachname anlegen, bearbeiten und löschen.
|
||||
- Aktivstatus wird gepflegt und dargestellt.
|
||||
- Diese Datensätze sind keine Login-Konten; die separate Benutzerrolle `ERZIEHER` ist hiervon unabhängig.
|
||||
- Referenz für Notdienstalarme im Datenmodell vorhanden, aber derzeit keine Fachkraftauswahl im Alarmauslöser.
|
||||
|
||||
Quellen: [Erzieheraktionen](../src/app/dashboard/erzieher/actions.ts), [Erzieheroberfläche](../src/app/dashboard/erzieher/_components/erzieher-list.tsx).
|
||||
|
||||
### 4.17 Systemweite Superadmin-Verwaltung
|
||||
|
||||
- Übersicht aller Kitas mit Slug, Modulstatus, Benutzer-/Familien-/Kinderzahlen, Terminen, Abwesenheiten, Meldungen, offenen Einladungen und letztem Login.
|
||||
- Hinweise auf Benutzer ohne Kita, Kontosperren, fehlende Datenschutzzustimmung und Familien ohne Benutzer.
|
||||
- Kita-Diagnose mit Benutzerliste, Rollen, Familien, Verifikationsstatus, Consent, Fehlversuchen und Sperren; zusätzlich Zähler für Konten ohne Passwort und abgelaufene Einladungen.
|
||||
- Diagnoseaufrufe werden im Audit-Protokoll gespeichert.
|
||||
- Kita endgültig löschen nach exakter Namensbestätigung, mit kaskadierender Löschung abhängiger Datenbankdatensätze.
|
||||
- Kita-gebundene Benutzer löschen nach Bestätigung ihrer E-Mail-Adresse; dabei zugehörige Verifikationstokens entfernen.
|
||||
- Benutzer für weitere Logins sperren, entsperren oder Fehlversuche zurücksetzen. Sperrung setzt `lockedUntil` weit in die Zukunft; Entsperren löscht die Sperre und setzt den Zähler zurück.
|
||||
- Eigene und andere Superadmin-Konten sowie Konten ohne Kita werden durch diese Support-Aktionen nicht verändert.
|
||||
- Auditansicht zeigt die neuesten 200 Einträge samt Akteur, Ziel, Zeitpunkt, Aktion und Metadaten. Protokolle können auch nach Löschung der Zielobjekte bestehen bleiben.
|
||||
|
||||
**Einschränkungen:** Offene und abgelaufene Einladungen werden über `Invitation` gezählt, während die tatsächlichen Familien-Einladungen `VerificationToken` nutzen. Eine Superadmin-Benutzerlöschung entfernt nicht automatisch einen nun leeren Haushalt. Eine Sperre wird bei der Anmeldung geprüft; das sofortige Beenden bereits bestehender Sessions ist nicht umgesetzt.
|
||||
|
||||
Quellen: [Adminübersicht und Diagnose-Daten](../src/app/admin/_lib/data.ts), [Support-Aktionen](../src/app/admin/actions.ts), [Auditseite](../src/app/admin/audit/page.tsx).
|
||||
|
||||
## 5. Datenmodell und technische Querschnittsfunktionen
|
||||
|
||||
- PostgreSQL mit Prisma. Eine Kita ist ein Mandant; fachliche Daten sind über `kitaId` zugeordnet.
|
||||
- Datenmodelle für Kitas, Benutzer, Haushalte, Kinder, Fachkräfte, feste Ämter, Diensttypen/-einteilungen, Abwesenheiten, Meldungen, Anhänge, Lesestatus, Termine, Teilnahme, Mitbringsel, Notdienstpläne/-verfügbarkeiten/-einteilungen/-alarme/-tausche sowie Auditprotokolle und Tokens.
|
||||
- Eindeutigkeitsregeln unter anderem für E-Mail, Kita-Slug, Monatsplan, Einteilung je Plantag, Verfügbarkeit je Kind/Tag, Teilnahme je Event/Kind und Amt je Benutzer.
|
||||
- Eingabeprüfung überwiegend mit Zod, Schreibabläufe teilweise als Datenbanktransaktionen, Aktualisierung von Seiten nach Änderungen.
|
||||
- Gemeinsamer E-Mail-Versand über Resend mit Fehlerbehandlung. Ohne API-Key werden E-Mails nur in der Entwicklungsumgebung simuliert; diese Simulation bestätigt keine Zustellung.
|
||||
- Vorlagen für Einladung, Alarm, Verfügbarkeitserinnerung, Einteilungsänderung, Diensttausch, Wochen-Diensterinnerung, News und Kontakt. Eine weitere Vorlage für erstmalige Notdienst-Zuteilung ist vorhanden, aber nicht an die Veröffentlichung angebunden.
|
||||
- Rollen-, Kita- und Consent-Prüfungen über gemeinsame Helfer. Geschützte Dashboard-/Adminlayouts, Seiten und Aktionen setzen Zugriffsprüfungen ein; nicht jede Aktion verwendet denselben Consent-Helfer.
|
||||
- Viele Datenbankrelationen löschen abhängige Datensätze mit. Das ist nicht gleichbedeutend mit vollständiger Bereinigung von Dateien, allgemeinen Verifikationstokens oder Audit-Snapshots.
|
||||
- Der Hilfsmodus `TEST_MODE=true` liefert in `requireRole` und `requireKitaSession` künstliche Sessions und umgeht dort die regulären Prüfungen. Er ist nicht auf die Entwicklungsumgebung begrenzt.
|
||||
|
||||
Quelle: [Schema](../prisma/schema.prisma), [Mail](../src/lib/mail.ts), [Auth-Helfer](../src/lib/auth-utils.ts).
|
||||
|
||||
## 6. Seiten- und Schnittstellenverzeichnis
|
||||
|
||||
| Route | Zweck/Zugriff |
|
||||
| --- | --- |
|
||||
| `/` | Öffentliche Startseite und Kontakt. |
|
||||
| `/features/notdienst-planer` | Öffentliche Notdienst-Beschreibung. |
|
||||
| `/features/kita-kalender` | Öffentliche Kalender-Beschreibung. |
|
||||
| `/features/eltern-adressbuch` | Öffentliche Adressbuch-Beschreibung. |
|
||||
| `/features/krankmeldung` | Öffentliche Abwesenheits-Beschreibung. |
|
||||
| `/features/schwarzes-brett` | Öffentliche Meldungs-Beschreibung. |
|
||||
| `/impressum`, `/datenschutz` | Öffentliche Informationsseiten mit noch offenen Platzhaltern. |
|
||||
| `/register`, `/login` | Gründerregistrierung und Anmeldung. |
|
||||
| `/onboarding` | Kita einrichten nach Anmeldung. |
|
||||
| `/onboarding/consent` | Hinweis bei fehlender Datenschutzannahme. |
|
||||
| `/invite/[token]` | Elternkonto über Einladungslink aktivieren. |
|
||||
| `/alert/[token]` | Notdienstalarm über Link bestätigen. |
|
||||
| `/forbidden` | Hinweis bei verweigertem Zugriff. |
|
||||
| `/dashboard` | Kita-Übersicht und eigene Abwesenheiten. |
|
||||
| `/dashboard/profil` | Eigener Haushalt, Kinder, Kontakte und Kontolöschung. |
|
||||
| `/dashboard/notdienst` | Verfügbarkeiten; Eltern, Admin, Koordinator. |
|
||||
| `/dashboard/notdienst/plan` | Planung; Admin, Koordinator. |
|
||||
| `/dashboard/kalender` | Kita-Kalender und Diensttausch. |
|
||||
| `/dashboard/adressbuch` | Internes Adressbuch. |
|
||||
| `/dashboard/families` | Familien und feste Ämter; Admin. |
|
||||
| `/dashboard/erzieher` | Fachkraft-Stammdaten; Admin. |
|
||||
| `/dashboard/admin/dienste` | Wochen-Dienstplan; Kita-Admin. Die Aktion erlaubt zusätzlich Superadmin mit Kita-Kontext. |
|
||||
| `/dashboard/admin/news` | Meldungen veröffentlichen; Admin. |
|
||||
| `/dashboard/admin/kalender` | Termine und Event-Anmeldungen; Admin, Koordinator. |
|
||||
| `/dashboard/admin/abwesenheiten` | Tagesübersicht; Admin, Koordinator, Erzieherrolle. |
|
||||
| `/admin` | Systemweite Kita-Übersicht; Superadmin. |
|
||||
| `/admin/kitas/[kitaId]` | Kita-Diagnose und Benutzersupport; Superadmin. |
|
||||
| `/admin/audit` | Systemweites Audit-Protokoll; Superadmin. |
|
||||
| `/api/auth/[...nextauth]` | Auth.js-Endpunkte. |
|
||||
| `/api/announcements/attachments/[id]` | Geschützter Anhangdownload. |
|
||||
| `/sitemap.xml`, `/robots.txt` | Suchmaschinensteuerung. |
|
||||
|
||||
## 7. Betrieb, Entwicklung und Prüfstand
|
||||
|
||||
- Stack laut `package.json`: Next.js 16.2.4, React 19.2.4, TypeScript, Prisma 6, Auth.js/NextAuth v5 beta, PostgreSQL, Tailwind CSS und Resend.
|
||||
- Lokaler Entwicklungsstart führt vor Next.js automatisch `prisma db push` und den Seed aus.
|
||||
- Seed legt Demonstrationsdaten mit mehreren Rollen und fachlichen Beispielen an; regulärer Seed ist bei bereits befüllter Datenbank zurückhaltend. Ein separater Reset-Modus existiert.
|
||||
- Docker-Build und Produktionsstart sind vorhanden; Produktionsstart wendet das Schema über `prisma db push --skip-generate` an. Versionierte Prisma-Migrationen liegen nicht im geprüften Dateibestand.
|
||||
- Lokale Compose-Datei stellt PostgreSQL bereit; Coolify-Compose enthält App, PostgreSQL, pgAdmin und persistente Datenbank-/Uploadvolumes.
|
||||
- Umgebungsvariablen-Vorlage und Produktionscheckliste sind vorhanden; ihre tatsächliche Erfüllung wurde nicht auf einem Server geprüft.
|
||||
- `UPLOAD_DIR` ist persistent vorgesehen. Die Dateihilfsfunktionen hängen noch `announcements` an diesen Pfad an; bei der aktuellen Compose-Konfiguration ergibt sich `/app/uploads/announcements/announcements`.
|
||||
- Kein Testskript und keine automatisierte Testdateisuite im geprüften Repository gefunden. Demo-Seed und manueller Live-Testplan ersetzen keine automatisierten Tests.
|
||||
|
||||
### Ausgeführte technische Prüfungen
|
||||
|
||||
Die Prüfungen liefen auf dem unveränderten Ausgangscheckout mit den dort bereits installierten Abhängigkeiten. Es wurden keine Bibliotheken installiert und keine Builds oder Datenbankoperationen ausgeführt.
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| `./node_modules/.bin/tsc --noEmit --incremental false` | Erfolgreich, Exit-Code 0. |
|
||||
| `npm run lint` | Fehlgeschlagen, Exit-Code 1: zwei Fehler und neun Warnungen. |
|
||||
| Quellenprüfung von Seiten, Aktionen, Komponenten und Schema | Durchführung für die oben beschriebenen Funktionsbereiche. |
|
||||
| Aktueller Browser-/End-to-End-Test | Nicht durchgeführt. |
|
||||
| Mailzustellung und produktives Deployment | Nicht geprüft. |
|
||||
|
||||
Beide ESLint-Fehler betreffen `as any` im Testmodus von `src/lib/auth-utils.ts` (Zeilen 57 und 91). Die Warnungen betreffen ungenutzte Variablen/Imports im Seed, Kalender, Adressbuch und Abwesenheiten. Diese Bestandsaufnahme ändert keinen Anwendungscode.
|
||||
|
||||
Der vorhandene [Live-Testplan](live-testplan.md) vom 17.05.2026 dokumentiert erfolgreiche Tests von Landingpage/Kontakt, Gründerregistrierung, Einrichtung, Dashboard, Familienanlage und Teilen des Login-Ablaufs. Eltern-Einladungsaktivierung und anschließende Bereinigung sind dort noch offen. Dies sind historische Einträge, keine aktuellen Testnachweise für den geprüften Commit. Die abgehakten Punkte in [UI-TODO.md](../UI-TODO.md) sind ebenfalls historische Angaben; viele der dort ehemals fehlenden Handler sind inzwischen im Code vorhanden.
|
||||
|
||||
Quellen: [Paket und Skripte](../package.json), [Deployment](../deploy/README.md), [Produktionscheckliste](../deploy/production-checklist.md), [Seed](../prisma/seed.ts), [Coolify-Compose](../docker-compose.coolify.yml).
|
||||
|
||||
## 8. Konkrete offene Punkte für die weitere Arbeit
|
||||
|
||||
Die Reihenfolge ist ein Vorschlag aus der Bestandsaufnahme; es wurde nichts davon umgesetzt.
|
||||
|
||||
| Reihenfolge | Thema | Konkreter nächster Schritt |
|
||||
| --- | --- | --- |
|
||||
| 1 | Fehlende Elternabläufe | NewsTicker und RSVP-Eingabe an eine erreichbare Seite anbinden; Daten dafür laden. |
|
||||
| 2 | Notdienstalarm | Auslösebutton, Fachkraftauswahl und Statusanzeige in den aktuellen Seitenaufbau integrieren. |
|
||||
| 3 | Moduldeaktivierung | Modulschalter in Navigation, Seiten und Server Actions einheitlich prüfen; spätere Einstellungen anbieten. |
|
||||
| 4 | Notdienstregeln | Wochenenden/Schließtage serverseitig ausschließen und Generierung um Schließtage ergänzen; Abwesenheitsregel festlegen. |
|
||||
| 5 | Erinnerungen | Automatische Ausführung ergänzen und unterscheiden, ob gar keine oder zu wenige Verfügbarkeiten vorliegen. |
|
||||
| 6 | Erstveröffentlichung | Zuteilungsmails beim Veröffentlichen anbinden und Mailfehler sichtbar behandeln. |
|
||||
| 7 | Einladungssupport | Tokenmodell und Superadmin-Zähler vereinheitlichen; erneuten Versand, Ablauf und Widerruf erreichbar machen. |
|
||||
| 8 | Rechtevergabe | Regulären Ablauf für Koordinator-/Erzieherkonten und Rollenänderungen ergänzen. |
|
||||
| 9 | Abwesenheitsgruppen | Echte Gruppenzuordnung einführen oder den künstlichen Gruppenfilter entfernen; Kennzahlen konsistent berechnen. |
|
||||
| 10 | Buchungskonflikte | Fachliche Reservierungsregeln definieren und Überschneidungen serverseitig prüfen. |
|
||||
| 11 | Selbstverwaltung | Passwort-Reset/-Änderung und direkte Bearbeitung von Abwesenheiten/Mitbringbeiträgen ergänzen. |
|
||||
| 12 | Löschabläufe | Kinderlöschung bei bestehenden Verpflichtungen prüfen; Anhangdateien und Tokens bei Kita-/Kontolöschungen bereinigen; Audit-Aufbewahrung gesondert definieren. |
|
||||
| 13 | Zugriffsprüfungen | Testmodus an Entwicklungsumgebung binden; Consent-/Session-Prüfungen und Teilnehmerhinweise serverseitig vereinheitlichen. |
|
||||
| 14 | Technischer Zustand | Zwei ESLint-Fehler beheben, Migrationsablauf einführen und aussagekräftige Tests für die zentralen Abläufe ergänzen. |
|
||||
| 15 | Aktuelle Abnahme | Rollenbasierte Browserprüfung, Mailzustellung, Elternaktivierung und Bereinigung anhand eines aktualisierten Testplans durchführen. |
|
||||
| 16 | Öffentliche Texte | Platzhalter in Impressum/Datenschutz mit den tatsächlichen Betriebsangaben vervollständigen und Marketingversprechen mit dem implementierten Verhalten abgleichen. |
|
||||
|
||||
## 9. Nicht als fertige Produktfunktion vorhanden
|
||||
|
||||
Im geprüften Code wurden kein Abonnement-/Zahlungsablauf, keine Import-/Exportverwaltung für Familien, kein externer Kalenderabgleich, keine Push-/SMS-Alarmierung, keine echte Kita-Gruppenverwaltung und keine Zeiterfassung/Check-in-Funktion gefunden. Vorhandene Planungs- oder Marketingtexte sind dafür kein Umsetzungsnachweis.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Notdienstalarm
|
||||
|
||||
Stand: 09.10.2026 · Umsetzung auf `codex/notdienstalarm`
|
||||
|
||||
## Bedienung
|
||||
|
||||
Kita-Admins und Notdienst-Koordination finden den Alarm in der Navigation und bei eingeteilten Tagen eines veröffentlichten Notdienstplans. Der Dialog zeigt die betroffene Familie und ihre Elternadressen vor dem Auslösen. Er umfasst einen ganzen Kita-Tag oder ein Zeitfenster, eine Antwortfrist (standardmäßig 30 Minuten), eine optionale interne Fachkraftzuordnung und eine interne Notiz.
|
||||
|
||||
Nur die eingeteilte Familie wird angefragt. Jedes Elternkonto erhält einen eigenen persönlichen Rückmeldelink und einen Hinweis in der App. Die Antwort „Wir übernehmen“ oder „Wir können nicht“ muss ausdrücklich gespeichert werden und gilt für den gesamten Haushalt. Das Öffnen des Links speichert keine Antwort. Die erste gespeicherte Antwort entscheidet auch bei gleichzeitigen Antworten; Änderungen dokumentiert die Koordination.
|
||||
|
||||
| Anzeige | Bedeutung / nächster Schritt |
|
||||
| --- | --- |
|
||||
| Antwort ausstehend | Haushalt hat noch nicht geantwortet. |
|
||||
| Übernahme bestätigt | Rechtzeitige Zusage oder dokumentierte Freigabe durch Koordination. |
|
||||
| Absage – Klärung erforderlich | Alle Admins und KoordinatorInnen der Kita erhalten sofort einen Hinweis. Ersatz manuell organisieren. |
|
||||
| Klärung erforderlich – Frist abgelaufen | Nach Ablauf informiert der Hintergrundjob einmalig die Admins und KoordinatorInnen. |
|
||||
| Verspätete Zusage | Muss durch Koordination freigegeben werden; keine automatische sichere Besetzung. |
|
||||
| Klärung erforderlich | Uneindeutige frühere Einzelantworten wurden übernommen. Haushalt kontaktieren. |
|
||||
| Storniert | Haushalt wird informiert; der Link kann keine Antwort mehr speichern. |
|
||||
|
||||
Telefonische Rückmeldungen, Korrekturen, verspätete Freigaben und Stornierungen benötigen eine interne Begründung. Interne Fachkraftdaten, Notizen und Verlauf erscheinen ausschließlich in der Verwaltungsansicht und werden nicht per E-Mail übermittelt. Eltern sehen ihre eigenen Alarme; andere Haushalte und Fachkräfte haben keinen Zugriff.
|
||||
|
||||
Aktive Alarme sperren manuelle Umzuordnung und Diensttausche. Für einen Ersatz zuerst den Alarm stornieren, dann die Einteilung ändern und neu alarmieren. Ein ausstehender E-Mail-Versand lässt sich erneut versuchen, ohne die Antwortfrist neu zu starten. Die Anzeige „vom Maildienst angenommen“ bestätigt die Annahme durch den Versanddienst, nicht die Zustellung im Postfach.
|
||||
|
||||
## Zeiten und Grenzen
|
||||
|
||||
- Kita-Zeitzone: `Europe/Berlin`, einschließlich Sommer-/Winterzeit. Nicht existierende Uhrzeiten bei der Zeitumstellung werden abgelehnt.
|
||||
- Ganze Tage enden um Mitternacht zum Folgetag. Zeitfenster müssen am selben Tag liegen; Ende nach Beginn.
|
||||
- Die Antwortfrist startet beim Auslösen, beträgt 1–1440 Minuten und endet spätestens mit dem Einsatz. Alarme sind für heute und zukünftige veröffentlichte Einteilungen möglich.
|
||||
- Nach Einsatzende können Rückmeldelinks keine Antwort mehr speichern. Der letzte Status bleibt lesbar.
|
||||
- Die Übersicht zeigt bis zu 100 Alarme seit 30 Tagen vor heute. Direkte Detail-Links öffnen auch ältere Vorgänge. Im Dialog stehen Einteilungen der nächsten 93 Tage zur Wahl; das Dashboard zeigt bis zu zehn aktuelle Alarme.
|
||||
- Kein SMS-/Push-Versand und keine automatische Alarmierung anderer Familien. Notdienstmails gehören zum Alarmablauf und verwenden nicht die persönliche Einstellung für Personal-Antragsmails.
|
||||
|
||||
## Betrieb und Hintergrundjob
|
||||
|
||||
Benötigt werden die vorhandene Resend-Konfiguration (`RESEND_API_KEY`, `EMAIL_FROM`), die korrekte öffentliche App-Adresse (`NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL`/`NEXTAUTH_URL`) und neu `NOTDIENST_JOB_SECRET` mit mindestens 32 Zeichen. Ein zufälliges Secret kann beispielsweise mit `openssl rand -hex 32` erzeugt und in der Hosting-Konfiguration gespeichert werden. Das Secret gehört nicht in Git oder ins Browser-Frontend.
|
||||
|
||||
Der bestehende Docker-Produktionsstart startet `scripts/notdienst-alarm-worker.mjs`. Dieser ruft nach fünf Sekunden und danach ungefähr jede Minute lokal `POST /api/jobs/notdienstalarme` auf. Fristmeldungen entstehen dadurch auch ohne geöffneten Browser, normalerweise innerhalb einer Minute nach Fristende, zuzüglich Laufzeit und möglicher Ausfälle. Bei fehlendem Secret startet der allgemeine Produktionsstart ohne Fristjob und die Verwaltungsseite zeigt einen Hinweis; die Coolify-Vorlage verlangt das Secret. Ein zu kurzes gesetztes Secret stoppt den Produktionsstart.
|
||||
|
||||
Bei einem anderen Hosting-Verfahren muss der Worker als eigener dauerhaft überwachter Prozess gestartet werden oder ein externer Scheduler die Route mindestens einmal pro Minute per POST und `Authorization: Bearer <NOTDIENST_JOB_SECRET>` aufrufen. Nur `npm run start` startet den Worker nicht. Der Scheduler/Host muss auch nach Neustarts laufen; ein gesetztes Secret allein belegt keine laufende Fristprüfung.
|
||||
|
||||
Der Job bearbeitet bis zu 100 Fristfälle und 100 E-Mail-Aufträge je Lauf. Wiederholte und gleichzeitige Aufrufe werden abgefangen: Fristmeldungen besitzen einen eindeutigen Ereignisschlüssel, Mailaufträge eine Versandreservierung und einen stabilen Idempotenzschlüssel. Versandfehler bleiben gespeichert und werden bei späteren Läufen erneut versucht; bereits angenommene Aufträge werden nicht erneut angefragt. Rollen- und Haushaltszuordnungen werden vor dem Versand erneut geprüft. Überholte Anfragen werden übersprungen. Provider-Garantien zur Idempotenz und tatsächliche Postfachzustellung bleiben Grenzen außerhalb der Anwendung.
|
||||
|
||||
HTTP 401 bedeutet fehlende/falsche Berechtigung, 503 fehlendes/zu kurzes Job-Secret, 500 einen fehlgeschlagenen Lauf. HTTP 200 enthält die Bearbeitungszahlen einschließlich fehlgeschlagener E-Mails; die Versandübersicht und Server-/Worker-Logs sind für die Betriebsprüfung heranzuziehen. Bei Ausfall wird die Fristüberschreitung schon anhand der Uhrzeit in der Oberfläche angezeigt; E-Mail-Eskalation und Reservierungsfreigabe benötigen den nächsten erfolgreichen Joblauf.
|
||||
|
||||
## Datenbank und vorhandene Alarme
|
||||
|
||||
Das Schema ergänzt einen gemeinsamen Alarmvorgang, einzelne Empfänger und Tokens, Hinweise, Mailaufträge und einen Verlauf. Die bisherigen `NotdienstAlert`-Datensätze bleiben erhalten. Ein aktiver Alarm reserviert seine Einteilung eindeutig; Transaktionen schützen konkurrierende Antworten und Planänderungen.
|
||||
|
||||
Der bestehende Docker-Start nutzt `prisma db push --skip-generate`. Für kontrollierte SQL-Upgrades liegt alternativ [deploy/sql/notdienstalarm.sql](../deploy/sql/notdienstalarm.sql) vor. Es setzt den vollständigen Personalbereich einschließlich seiner Mail-Erweiterung voraus, ist einmalig auszuführen und enthält keine Datenlöschung. Nicht zusätzlich ausführen, wenn `db push` diese Änderungen bereits angewandt hat. Datenbanksicherung und Prüfung des Ausgangsschemas gehören zum Deployment. Die Auswahl der SQL-Alternativen erklärt die [zentrale Deployment- und Mailanleitung](../deploy/DEPLOYMENT_UND_MAILVERSAND.md).
|
||||
|
||||
Bisherige Einzelalarme werden beim Verwaltungsaufruf, Joblauf oder Aufruf eines alten Links in Gruppen pro Einteilung übernommen. Alte gültige Tokens bleiben nutzbar; Originalantworten bleiben gespeichert. Nur vollständig übereinstimmende gültige Antworten ergeben eine sichere gemeinsame Zusage. Widersprüche, fehlende/ungeklärte Empfänger oder doppelte Einzelalarme führen zu Klärungsbedarf. Bei doppelten Datensätzen eines Elternkontos wird ein Rückmeldetoken übernommen; die übrigen Originaldatensätze bleiben erhalten. Die Übernahme verschickt keine erneute Alarmanfrage.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Abschließender Prüfstand: 20 Alarmtests, 17 Personal-Service-Tests und neun Berechnungstests erfolgreich; TypeScript, ESLint der betroffenen Dateien und Produktionsbuild erfolgreich. Das additive SQL-Upgrade wurde auf einem separaten Schema der isolierten Datenbank angewandt und per Prisma mit dem Zielmodell verglichen: keine Abweichungen. Worker und Jobroute wurden lokal geprüft (401 ohne Secret, 200 mit Secret, Fristmeldung je zuständigem Konto einmalig). Die vorhandenen globalen ESLint-Fehler in `auth-utils.ts` liegen außerhalb dieser Änderung.
|
||||
|
||||
Automatisierte Alarmtests prüfen Rollen und Mandanten, gemeinsame und konkurrierende Antworten, Fristprüfung, Absagen, verspätete Freigaben, Stornierung, Tokens, Versandfehler/erneuten Versand, Altbestände sowie die Sperre vorhandener Plan- und Diensttausch-Aktionen. Sie benötigen ausdrücklich die isolierte lokale Testdatenbank `127.0.0.1:55439/kita_staff_test` und bereinigen nur ihre eigenen Test-Kitas.
|
||||
|
||||
```sh
|
||||
DATABASE_URL='postgresql://USER@127.0.0.1:55439/kita_staff_test' npm run test:alarms
|
||||
npm run test:staff
|
||||
```
|
||||
|
||||
Die Browserprüfung verwendet ausschließlich Demo-Daten und deaktivierten tatsächlichen E-Mail-Versand. Geprüft wurden Auslösen aus der Einteilung, Zeitfenster, Eltern-Dashboard-Hinweis, persönliche Rückmeldeseite mit expliziter Bestätigung und eine mobile Ansicht ohne horizontalen Überlauf. Produktive Zustellung und Deployment sind separat durchzuführen.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Notdienstalarm – abgestimmter Funktionsplan
|
||||
|
||||
Stand: 09.10.2026 · Branch `codex/notdienstalarm`
|
||||
|
||||
Status: Umgesetzt. Dieser Plan hält die abgestimmten Entscheidungen fest. Bedienung, Betriebsanforderungen und Prüfungen stehen in [NOTDIENSTALARM.md](NOTDIENSTALARM.md).
|
||||
|
||||
## Festgelegter Umfang
|
||||
|
||||
| Thema | Entscheidung |
|
||||
| --- | --- |
|
||||
| Zuständigkeit | Nur Kita-Admins und Notdienst-Koordination (`ADMIN`, `KOORDINATOR`) lösen Alarme aus und verwalten sie. |
|
||||
| Erste Empfänger | Ausschließlich die für das Datum im veröffentlichten Notdienstplan eingeteilte Familie. Alle Elternkonten dieses Haushalts werden einzeln angeschrieben. |
|
||||
| Rückmeldung | „Wir übernehmen“ oder „Wir können nicht“. Eine Antwort gilt für den gesamten Haushalt. |
|
||||
| Kanäle | E-Mail und ein deutlich sichtbarer Hinweis in der App. |
|
||||
| Antwortfrist | Standardmäßig 30 Minuten ab Auslösung; beim Auslösen anpassbar. |
|
||||
| Einsatzdauer | Wahl zwischen „Ganzer Kita-Tag“ und „Bestimmte Uhrzeiten“ mit Beginn und Ende. |
|
||||
| Fehlende Übernahme | Absage sofort, ausbleibende Antwort nach Fristablauf: „Klärung erforderlich“. Koordination informieren; Ersatz manuell organisieren. |
|
||||
|
||||
## Ablauf in der Oberfläche
|
||||
|
||||
### 1. Alarm auslösen
|
||||
|
||||
Im veröffentlichten Notdienstplan erhält die Einteilung eine Aktion **Notdienstalarm auslösen**. Eine aktuelle Alarmübersicht ist zusätzlich über das Dashboard erreichbar.
|
||||
|
||||
Der Dialog zeigt Datum, eingeteilte Familie und deren Empfängeradressen. Die auslösende Person wählt die Einsatzdauer, prüft die Antwortfrist und bestätigt den Versand. Bei **Bestimmte Uhrzeiten** sind Beginn und Ende Pflicht; Ende liegt nach Beginn. Bei **Ganzer Kita-Tag** sind keine künstlichen Uhrzeitangaben nötig. Die gewählte Dauer erscheint in der E-Mail und auf der Rückmeldeseite.
|
||||
|
||||
Ein Alarm ist nur für heutige oder zukünftige Einteilungen eines veröffentlichten Plans der eigenen Kita möglich. Notdienst muss für die Kita aktiviert sein. Fehlende Elternkonten werden vor der Auslösung deutlich angezeigt. Eine aktive Alarmierung verhindert einen zweiten Alarm für dieselbe Einteilung.
|
||||
|
||||
Optional kann die ausgefallene Fachkraft intern zugeordnet werden. Dies dient der Zuordnung und erzeugt keine automatische Krankheitsbuchung im Personalbereich. Private interne Notizen und Namen ausgefallener Fachkräfte gehören nicht in die Eltern-E-Mail.
|
||||
|
||||
### 2. Familie informieren und Antwort erfassen
|
||||
|
||||
Jedes Elternkonto erhält eine eigene E-Mail mit Kita, Datum, Einsatzdauer, Antwortfrist und persönlichem Rückmeldelink. Die Nachricht fragt eindeutig nach einer Übernahme. Alle Elternkonten des Haushalts sehen in der App denselben aktiven Alarm.
|
||||
|
||||
Die Rückmeldeseite funktioniert wie bisher ohne Login über einen persönlichen Token. Das Öffnen des Links löst keine Zusage aus. Erst die ausdrückliche Auswahl von **Wir übernehmen** oder **Wir können nicht** mit Bestätigung schreibt die Antwort.
|
||||
|
||||
Die Antwort wird am gemeinsamen Haushaltseinsatz gespeichert, einschließlich Zeitpunkt und antwortendem Elternkonto. Alle anderen Links zeigen anschließend die gemeinsame Antwort. Gleichzeitige gegensätzliche Antworten dürfen nicht beide wirksam werden. Eine bereits gespeicherte Antwort lässt sich nicht versehentlich durch einen zweiten Klick überschreiben.
|
||||
|
||||
### 3. Stand verfolgen und Ersatz klären
|
||||
|
||||
Die Alarmübersicht zeigt Datum, Dauer, Familie, Antwortfrist, gemeinsamen Rückmeldestatus und den Versandstand je Empfänger. Offene Alarme für heute werden besonders hervorgehoben.
|
||||
|
||||
| Anzeige | Bedeutung und nächste Aktion |
|
||||
| --- | --- |
|
||||
| Antwort ausstehend | Familie wurde angefragt; die Antwortfrist läuft. |
|
||||
| Übernahme bestätigt | Der Haushalt hat rechtzeitig zugesagt. Koordination und Admins sehen die Zusage. |
|
||||
| Klärung erforderlich – Absage | Der Haushalt kann nicht übernehmen. Koordination organisiert Ersatz. |
|
||||
| Klärung erforderlich – Frist abgelaufen | Es liegt keine Antwort vor. Koordination fasst nach oder organisiert Ersatz. |
|
||||
| Storniert | Einsatz entfällt; Rückmeldelinks erlauben keine weitere Antwort. |
|
||||
|
||||
Versandfehler sind ein eigener Zustand und dürfen nicht als Absage der Familie erscheinen. Eine fehlgeschlagene E-Mail wird deutlich angezeigt und kann erneut versendet werden; andere Empfänger werden trotzdem bedient.
|
||||
|
||||
Bei Absage oder Fristablauf erhalten zuständige Koordinations- und Admin-Konten derselben Kita einen Hinweis in der App und eine E-Mail. Die Fristmeldung wird je Alarm einmal erzeugt. Die Koordination organisiert Ersatz in dieser Version manuell; weitere Familien werden nicht automatisch alarmiert.
|
||||
|
||||
## Vorgeschlagene Ausgestaltung für Sonderfälle
|
||||
|
||||
Diese Details ergänzen die Nutzerentscheidungen als Umsetzungsvorschlag:
|
||||
|
||||
- **Verspätete Zusage:** Nach Fristablauf bleibt eine Rückmeldung möglich, solange der Einsatz läuft und der Alarm nicht storniert ist. Eine verspätete Zusage wird als solche angezeigt und muss von der Koordination bestätigt werden, damit sie nicht mit bereits organisiertem Ersatz kollidiert.
|
||||
- **Telefonische Rückmeldung:** Admin oder Koordination kann eine Zusage bzw. Absage für den Haushalt dokumentieren. Herkunft, handelndes Konto und Zeitpunkt bleiben nachvollziehbar.
|
||||
- **Antwort ändern:** Eine bereits dokumentierte Antwort wird nur durch Admin oder Koordination korrigiert. Eltern erhalten auf der Rückmeldeseite einen Hinweis, die Kita bei Änderungen zu kontaktieren.
|
||||
- **Stornieren:** Admin oder Koordination storniert mit einer internen Begründung. Alle angeschriebenen Elternkonten werden informiert; offene Links werden unwirksam. Eine neue Alarmierung derselben Einteilung ist danach als neuer Vorgang möglich.
|
||||
- **Token-Ende:** Für einen ganzen Kita-Tag endet die Rückmeldemöglichkeit spätestens mit dem Datum in Europe/Berlin; bei bestimmten Uhrzeiten mit dem Einsatzende. Die 30-Minuten-Frist für Eskalation ist davon unabhängig.
|
||||
- **Planänderungen und Tausch:** Aktive Alarme schützen die zugehörige Einteilung vor einem unbemerkten Familienwechsel oder Diensttausch. Ersatz darf nicht durch Änderung eines noch aktiven Alarms auf eine andere Familie übertragen werden.
|
||||
- **Erinnerung und erneuter Versand:** Eine Wiederholung startet die Antwortfrist nicht automatisch neu. E-Mail-Annahme durch den Versanddienst, Rückmeldung und tatsächliche Anwesenheit bleiben getrennte Informationen.
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
Der bestehende `NotdienstAlert` speichert pro Elternkonto einen unabhängigen Status. Für die gemeinsame Haushaltsantwort wird ein übergeordneter Alarmvorgang benötigt; Empfänger, Tokens und Versandversuche sind untergeordnet. Bestehende Datensätze müssen bei der Schemaumstellung berücksichtigt werden, ohne widersprüchliche Einzelantworten als sichere Haushaltszusage zu übernehmen.
|
||||
|
||||
Am Vorgang werden Einteilung und Haushalt, auslösendes Konto, Zeitpunkt, Einsatzart, gegebenenfalls Uhrzeiten, Antwortfrist, gemeinsame Antwort, interne Zuordnung und Stornierungsdaten festgehalten. Empfängerdatensätze führen persönliche Tokens und deren Gültigkeit. Historie und Versandaufträge erlauben Wiederholung und nachvollziehbare Änderungen.
|
||||
|
||||
Alle Aktionen prüfen aktuelle Datenbankrolle, Kita-Zugehörigkeit und Zugriff serverseitig. Auslösung und gemeinsame Rückmeldung laufen transaktional. Eine Datenbanksicherung verhindert mehrere aktive Alarme für dieselbe Einteilung sowie widersprüchliche parallele Antworten.
|
||||
|
||||
Der Versand verwendet die vorhandene Resend-Anbindung mit persistenten Versandaufträgen. Elternmails enthalten ausschließlich notwendige Einsatzdaten. Interne Begründungen, Krankheitsdetails und Daten aus Personalzeitkonten werden nicht übernommen. Alarm-E-Mails sind unabhängig von der Abschalteinstellung für Personal-Entscheidungsmails.
|
||||
|
||||
Die Fristüberwachung braucht eine regelmäßig aufgerufene serverseitige Prüfung, auch wenn niemand die App geöffnet hat. Vorgesehen ist ein geschützter, einmal pro Minute aufgerufener Serverjob. Die Anzeige berechnet Fristablauf zusätzlich direkt aus der gespeicherten Antwortfrist. Die Benachrichtigung kann durch das Prüfintervall und den Mailtransport etwas nach Ablauf eintreffen. Für die Einführung muss der Job in der Hosting-Umgebung eingerichtet werden.
|
||||
|
||||
## Umsetzungsschritte
|
||||
|
||||
1. Gemeinsamen Alarmvorgang, Empfänger, Tokens, Verlauf und Schemaumstellung ergänzen.
|
||||
2. Auslösung, Haushaltsantwort, Korrektur, Stornierung und erneuten Versand absichern.
|
||||
3. E-Mail-Vorlagen, Versandaufträge und Benachrichtigung der Koordination anbinden.
|
||||
4. Alarmdialog, Rückmeldeseite, Dashboard-Hinweise und Statusübersicht umsetzen.
|
||||
5. Fristprüfung, Hosting-Konfiguration und Einführungsdokumentation ergänzen.
|
||||
6. Gemeinsame Haushaltsantworten, Fristen, Rollen, Versandfehler und Browserabläufe prüfen.
|
||||
|
||||
## Abnahmefälle
|
||||
|
||||
- Admin und Koordination dürfen auslösen; Eltern, Fachkräfte und Konten anderer Kitas dürfen es nicht.
|
||||
- Nur die im veröffentlichten Plan eingeteilte Familie wird zunächst angeschrieben.
|
||||
- Ganzer Kita-Tag und bestimmte Uhrzeiten erscheinen in E-Mail, App und Rückmeldeseite konsistent.
|
||||
- Eine Zusage oder Absage wirkt auf den gesamten Haushalt; weitere persönliche Links zeigen denselben Stand.
|
||||
- Zwei gleichzeitige Antworten, doppelte Auslösung und Fristprüfung erzeugen keine doppelten Vorgänge oder widersprüchlichen Zusagen.
|
||||
- Absage erzeugt sofort Klärungsbedarf; Fristablauf nach 30 Minuten erzeugt ihn auch bei geschlossener App.
|
||||
- Versandfehler verhindern keine Speicherung, unterbrechen nicht den Versand an weitere Empfänger und lassen sich gezielt wiederholen.
|
||||
- Verspätete Antwort, telefonisch dokumentierte Antwort und Stornierung bleiben nachvollziehbar.
|
||||
- Stornierte und zeitlich abgelaufene Links können keinen aktiven Einsatz zusagen.
|
||||
- Aktive Alarme schützen Einteilungen vor unbemerktem Tausch oder Familienwechsel.
|
||||
|
||||
## Vorhandene Grundlage
|
||||
|
||||
- `src/app/dashboard/actions.ts`: Auslösung und E-Mail-Versand, bisher ein separater Alarm pro Elternkonto.
|
||||
- `src/app/dashboard/alert-button.tsx`: Vorbereiteter, noch nicht eingebundener Auslösebutton.
|
||||
- `src/app/alert/[token]/`: Rückmeldeseite und Bestätigung, bisher nur Zusage und ohne zeitliche Token-Gültigkeit.
|
||||
- `src/app/dashboard/kalender/actions.ts`: Bestehender Tauschausschluss für aktive Alarmierungen.
|
||||
- `src/lib/mail.ts` und `src/lib/staff/email.ts`: Mailtransport sowie Vorlage für persistente, wiederholbare Versandaufträge.
|
||||
|
||||
Die Planung löst keine Alarme aus und versendet keine E-Mails.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Personalbereich: Arbeitszeit, Urlaub und Zeitkonto
|
||||
|
||||
Stand: 09.10.2026
|
||||
|
||||
## Einstieg und Zuständigkeiten
|
||||
|
||||
Der Bereich liegt unter `/dashboard/personal`. Kita-Admins (einschließlich Vorstand mit Rolle `ADMIN`) verwalten alle Personalprofile ihrer Kita. ErzieherInnen bearbeiten eigene Zeiten und Anträge. Eltern und Koordination haben keinen Zugriff. Der interne Teamkalender ist nur für Admins und ErzieherInnen zugänglich.
|
||||
|
||||
Neue Fachkräfte werden unter **ErzieherInnen → ErzieherIn anlegen** in einem Formular eingerichtet:
|
||||
|
||||
1. Vorname, Nachname und E-Mail-Adresse (Pflichtfelder).
|
||||
2. Tatsächlicher Beschäftigungsbeginn und separat der Start der Zeiterfassung (vorbelegt mit heute, änderbar). Die Erfassung darf nicht vor der Beschäftigung beginnen.
|
||||
3. **Fachkraft anlegen und einladen** speichert Stammdaten, Personalprofil, Accountverknüpfung und Mailauftrag gemeinsam in einer Transaktion. Unpassende oder schon verknüpfte E-Mail-Adressen verhindern die gesamte Anlage.
|
||||
4. Anschließend öffnet sich das Personalprofil. Dort werden datierte Netto-Sollstunden für Montag bis Sonntag, Jahresurlaub und ausdrücklich festgelegter Übertrag sowie vorhandene Überstunden eingerichtet. Der Anfangssaldo startet mit 0 Minuten und muss bei vorhandenen Überstunden vor der ersten Zeiterfassung angepasst werden.
|
||||
5. Die örtlichen Feiertage werden unter **Teamkalender** hinterlegt. Sie gelten separat von den Schließtagen des allgemeinen Kalenders.
|
||||
|
||||
Vorhandene Fachkräfte können ihre fehlende E-Mail und gegebenenfalls die beiden Startdaten direkt über **Bearbeiten** erhalten. Bereits eingerichtete Beschäftigungsdaten, Wochenmodelle und Konten bleiben dabei erhalten. Die E-Mail eines verknüpften Accounts wird angezeigt und kann im Stammdatendialog nicht geändert werden; Namensänderungen werden auch im verknüpften Fachkraft-Account übernommen.
|
||||
|
||||
Die Teamübersicht zeigt E-Mail und Zugangszustand: **Zugang fehlt**, **Einladung ausstehend**, **Account aktiviert** oder **Mailversand fehlgeschlagen**. Für offene Einladungen steht **Einladung erneut senden** zur Verfügung. Ein neuer Link ersetzt den alten. Ein Mailfehler entfernt keine Fachkraft: Er bleibt sichtbar, der Versand kann erneut angestoßen werden. Im Personalbereich kann außerdem der bestehende Mailauftrag erneut versucht werden.
|
||||
|
||||
Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Neue Accounts erhalten einen persönlichen, einmal verwendbaren Aktivierungslink mit sieben Tagen Gültigkeit per E-Mail; im Personalprofil kann der Admin zusätzlich einen Link zum Kopieren erzeugen. Ein vorhandener verifizierter Fachkraft-Account derselben Kita ohne andere Profilverknüpfung wird verbunden, ohne eine Aktivierungsmail zu verschicken. Accounts anderer Kitas oder Rollen werden nicht übernommen.
|
||||
|
||||
Die gemeinsame Anlage benötigt keine zusätzlichen Datenbankfelder oder Umgebungsvariablen. Sie verwendet die bestehende Resend-Anbindung; siehe [Deployment und Mailversand](../deploy/DEPLOYMENT_UND_MAILVERSAND.md).
|
||||
|
||||
## Arbeitszeit und Überstunden
|
||||
|
||||
Pro Tag sind mehrere Arbeitsblöcke mit Beginn, Ende und Pause möglich. Zeitblöcke dürfen nicht überlappen. Die Pause wird in Minuten vom Block abgezogen. Zeiten werden für heutige oder vergangene Tage innerhalb der Beschäftigung und ab Erfassungsbeginn erfasst. Blöcke über Mitternacht werden auf zwei Tage aufgeteilt.
|
||||
|
||||
Die Tagesbilanz ist **Istzeit + Urlaub/Krankheit − Sollzeit**. Arbeit an einem freien Tag oder Feiertag zählt als zusätzliche Istzeit. Die Wochenansicht zeigt alle sieben Tage; in einer laufenden Woche fehlen zukünftige Arbeitszeiten noch, deshalb ist ihre Differenz vorläufig.
|
||||
|
||||
Die Fachkraft reicht ihre Woche nach dem letzten vorgesehenen Arbeitstag ein. Offene Abwesenheiten im Zeitraum müssen zuerst geklärt sein. Der Admin prüft die einzelnen Zeiten, genehmigt die Woche oder gibt sie mit Rückmeldung zurück. Eingereichte und genehmigte Wochen sperren Zeitänderungen. Eigene Wochen und eigene Anträge können nicht selbst freigegeben werden.
|
||||
|
||||
Beim Genehmigen wird die Tagesberechnung gespeichert. Nur genehmigte Wochen, der Anfangssaldo und wirksame manuelle Korrekturen zählen zum bestätigten Zeitkonto. Offene oder eingereichte Wochen wirken noch nicht auf dieses Konto. Eine Rückgabe entfernt die Wochenbuchung bis zur erneuten Freigabe. Fehler in manuellen Korrekturen werden durch eine begründete Gegenbuchung behoben.
|
||||
|
||||
## Feste Wochentage und seltene Wechsel
|
||||
|
||||
Wochenmodelle gelten ab ihrem Datum. Eine spätere Änderung überschreibt kein früheres Modell. Seltene Tausche werden über zwei Tagesausnahmen erfasst: der bisherige Arbeitstag erhält 0 Sollstunden, der Ersatztermin die vorgesehenen Stunden.
|
||||
|
||||
Genehmigte Abwesenheiten sowie eingereichte oder genehmigte Wochen schützen ihren Zeitraum auch vor Änderungen an Modellen, Ausnahmen und Feiertagen. Betroffene Freigaben müssen vor einer solchen Änderung zurückgegeben bzw. storniert werden. Grunddaten und Anfangssaldo werden nach der ersten Erfassung geschützt; das Beschäftigungsende bleibt über einen eigenen Ablauf verwaltbar, sofern bestehende Zeiten und Anträge nicht verletzt werden.
|
||||
|
||||
## Urlaub
|
||||
|
||||
Urlaub ist ganztägig oder für einen einzelnen halben Tag möglich. Nur vorgesehene Arbeitstage verbrauchen Urlaub. Wochenenden sind nicht pauschal frei: Entscheidend sind Wochenmodell und Tagesausnahmen. Feiertage verbrauchen keinen Urlaub und erzeugen keine Sollzeit. Ein halber Urlaubstag schreibt die Hälfte der Tages-Sollzeit gut; bei ungerader Minutenzahl wird auf eine ganze Minute gerundet.
|
||||
|
||||
Ein Jahreskonto führt Anspruch plus Übertrag, genommenen Urlaub, genehmigten geplanten Urlaub, offene Anträge und verfügbaren Rest. Offene Anträge werden bei weiteren Anträgen berücksichtigt. Abgelehnte und stornierte Anträge verbrauchen nichts. Zeiträume über den Jahreswechsel werden auf die jeweiligen Jahreskonten verteilt. Anspruch und Übertrag werden nicht automatisch anteilig berechnet oder zum Jahreswechsel übertragen.
|
||||
|
||||
Offene Anträge können geändert oder zurückgezogen werden. Bei genehmigten Anträgen wird eine Stornierung beantragt. Bis zur Entscheidung bleibt die Abwesenheit gebucht. Krankheit hinterlegt der Admin; sie entlastet die Sollzeit, ohne Urlaub zu verbrauchen. KollegInnen sehen Krankheit im Teamkalender nur als **Abwesend**, ohne persönliche Notizen oder Kontodaten.
|
||||
|
||||
## Freizeitausgleich
|
||||
|
||||
Freizeitausgleich wird für einen einzelnen vorgesehenen Arbeitstag in Stunden beantragt. Das bestätigte verfügbare Guthaben muss ausreichen; weitere offene Anträge werden dabei berücksichtigt.
|
||||
|
||||
Nach Genehmigung wird die Zeit reserviert. Freizeitausgleich erzeugt keine zusätzliche Istzeit oder Urlaubsgutschrift. Beispiel: 6 Stunden Soll, 5 Stunden Arbeit und 1 Stunde Freizeitausgleich ergeben eine Tagesbilanz von −1 Stunde. Mit der Wochenfreigabe wird diese Stunde im Zeitkonto gebucht und die Reservierung entfällt. Dadurch wird die Stunde genau einmal abgezogen. Bei offenen Stornierungen bleibt die Reservierung erhalten.
|
||||
|
||||
## Hinweise und Nachvollziehbarkeit
|
||||
|
||||
Neue Anträge und eingereichte Wochen erzeugen interne Hinweise für Kita-Admins. Entscheidungen und Rückgaben erzeugen Hinweise für die betroffene Fachkraft. Hinweise lassen sich als gelesen markieren; jede Person kann ausschließlich eigene Hinweise quittieren. Das Personalprofil zeigt die letzten 30 Änderungen. Im Datenmodell werden sämtliche Änderungen mit Zeitpunkt und handelnder User-ID gespeichert.
|
||||
|
||||
Parallele Änderungen laufen in serialisierbaren Datenbanktransaktionen mit Wiederholung bei Schreibkonflikten. Rollen, Accountstatus, Eigentümer und Kita-Zugehörigkeit werden serverseitig geprüft. Das Löschen von Fachkräften mit Personalprofil wird verhindert; sie können auf inaktiv gesetzt werden. Bei Accountlöschung bleiben Personalaufzeichnungen ohne Accountverknüpfung erhalten. Die Löschung einer Kita entfernt auch ihren Personalbereich.
|
||||
|
||||
## Einführung und Schema
|
||||
|
||||
Das Projekt verwendet weiterhin seinen vorhandenen Ablauf mit `prisma db push`. Es gibt keine vollständige versionierte Prisma-Migrationshistorie. Die neuen Tabellen sind additiv; vorhandene Personen erhalten noch kein automatisch befülltes Beschäftigungsprofil.
|
||||
|
||||
- Lokal: Abhängigkeiten installieren, Prisma-Client generieren, Schema gegen eine dafür vorgesehene Datenbank anwenden, App starten und Profile einrichten.
|
||||
- Im vorhandenen Docker-Ablauf erzeugt `npm run build:prod` den Client; `scripts/start-production.sh` wendet das Schema vor App-Start an.
|
||||
- [Prüfbares SQL](../deploy/sql/personalbereich.sql) beschreibt dieselbe additive Änderung gegen den dokumentierten Ausgangsstand. Es ist einmalig und nicht idempotent. Entweder den vorhandenen Schemaabgleich **oder** dieses SQL verwenden; das SQL nicht nach bereits erfolgtem Schemaabgleich erneut ausführen.
|
||||
- Ist der Personalbereich bereits auf Stand `043fed2` eingeführt, beschreibt [Personalbereich-E-Mail-SQL](../deploy/sql/personalbereich-email.sql) ausschließlich die zusätzlichen Mail-Tabellen und die Profileinstellung. Auch dieses Skript ist eine einmalige Alternative zum Schemaabgleich.
|
||||
|
||||
Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht verändert. Alle DB- und Browserprüfungen liefen mit synthetischen Daten in einer separaten lokalen PostgreSQL-Datenbank.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
- `npm run test:staff`: Berechnungstests für Teilzeit, halbe Tage, Feiertage, Jahreswechsel, Statuswechsel, Pausen, Snapshots und Freizeitausgleich.
|
||||
- `npm run test:staff:integration`: echte Datenbankabläufe für Mandantentrennung, Rollen und Eigentümer, Anträge, Freigaben, parallele Änderungen, Sperren, Rückgaben, Aktivierungslinks und Beschäftigungsende.
|
||||
- Integrationstests akzeptieren ausschließlich `127.0.0.1:55439/kita_staff_test`. Beispiel: `DATABASE_URL=postgresql://USER@127.0.0.1:55439/kita_staff_test npm run test:staff:integration`. Die leere Testdatenbank vorher mit dem aktuellen Schema einrichten. Tests bereinigen nur ihre eigens erzeugten Testkitas.
|
||||
- Browserprüfung: Admin richtet Profil, 18-Stunden-Woche und 18 Urlaubstage ein; Erzieherin erfasst drei Blöcke mit Pausen, reicht eine 19-Stunden-Woche ein und beantragt einen Urlaubstag. Nach Adminfreigabe zeigt das Zeitkonto 11 Stunden (10 Stunden Anfangssaldo + 1 Stunde Wochenplus) und das Urlaubskonto 17 Tage. Der Urlaub erscheint im Teamkalender.
|
||||
|
||||
Ergebnisse der abschließenden Prüfung:
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Berechnungstests | 9 bestanden. |
|
||||
| Integrationstests | 25 bestanden; einschließlich gemeinsamer Anlage, vollständigem Rollback bei E-Mail-Konflikten, Bearbeitung bestehender Fachkräfte, Kontenschutz, Rollenprüfung und Empfänger, Opt-out, Versandfehler, Token-Erneuerung und paralleler Versandversuche. Testkitas danach bereinigt. |
|
||||
| TypeScript | Erfolgreich. |
|
||||
| ESLint für sämtliche geänderten Anwendungsteile und neue Tests | Erfolgreich, ohne Warnungen. |
|
||||
| Projektweites ESLint | Die bereits vorhandenen zwei `as any`-Fehler in `src/lib/auth-utils.ts` und neun Warnungen bestehen weiterhin. |
|
||||
| Endgültiger Produktionsbuild | Erfolgreich, einschließlich `/dashboard/personal`. |
|
||||
| Vollständiges und inkrementelles SQL-Skript | Erfolgreich in isolierten Prüfschemata; anschließend jeweils kein Unterschied zum Prisma-Schema. |
|
||||
| Browser | Admin- und ErzieherInnen-Ablauf einschließlich Freigaben erfolgreich. Mobile Ansicht und Arbeitszeitdialog ohne Seitenüberlauf geprüft. Abschalten der E-Mail-Hinweise im Demo-Profil gespeichert und nach Neuladen verifiziert; anschließend wieder eingeschaltet. |
|
||||
|
||||
Die vom bestehenden Build gemeldete Warnung zu mehreren Lockfiles bzw. dem Turbopack-Arbeitsverzeichnis und die bestehende Prisma-Konfigurationswarnung sind unabhängig vom Personalbereich. Eine produktive Bereitstellung oder Mailzustellung wurde nicht durchgeführt.
|
||||
|
||||
## Prüfung der gemeinsamen Fachkraft-Anlage (09.10.2026)
|
||||
|
||||
- 25 Personal-Integrationstests, TypeScript, ESLint der geänderten Anwendungsteile und Produktionsbuild erfolgreich.
|
||||
- Lokale Browserprüfung mit synthetischen Daten: Datum vor Beschäftigungsbeginn abgewiesen, Eingaben erhalten, korrigierte Anlage erfolgreich und unmittelbar zum Personalprofil weitergeleitet.
|
||||
- Resend im lokalen Produktionsbuild bewusst deaktiviert: gespeicherte Fachkraft und wiederholbarer Mailfehler in Personalbereich und Teamliste sichtbar; erneute Einladung aus der Teamliste erfolgreich ausgelöst.
|
||||
- Bestehende Fachkraft ohne Zugang zeigt E-Mail und fehlende Startdaten direkt im Bearbeitungsdialog. Mobilprüfung bei 390 × 844 Pixeln: Dialog ohne horizontalen Überlauf, längerer Inhalt innerhalb des Dialogs scrollbar.
|
||||
- Keine echten Aktivierungsmails versendet; keine Änderung an Produktionsdaten vorgenommen.
|
||||
|
||||
## E-Mail-Hinweise
|
||||
|
||||
Die E-Mail-Erweiterung wurde am 08.10.2026 ausdrücklich beauftragt. Sie verwendet die vorhandene Resend-Anbindung:
|
||||
|
||||
| Nachricht | Empfänger | Inhalt |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Accountaktivierung | Vom Kita-Admin eingetragene E-Mail-Adresse der Fachkraft | Kita, Name, persönlicher Aktivierungslink mit sieben Tagen Gültigkeit. |
|
||||
| Antrag / Änderung / Stornierung oder Arbeitswoche eingereicht | Alle Kita-Admins derselben Kita, einzeln angeschrieben | Name, Zeitraum und Hinweis auf Prüfung im geschützten Personalbereich. |
|
||||
| Entscheidung / Rückgabe | Account-Adresse der betroffenen Fachkraft | Art, Zeitraum und Status mit Link in den geschützten Personalbereich. |
|
||||
|
||||
Fachkräfte können Entscheidungsmails unter **Mein Profil → E-Mail-Hinweise** abschalten. Aktivierungslinks und interne App-Hinweise bleiben davon unabhängig. Krankheitsbuchungen erzeugen keine E-Mail. Private Notizen, Entscheidungsbegründungen und Zeit- oder Urlaubskontostände werden nicht per E-Mail übertragen.
|
||||
|
||||
Versandaufträge werden innerhalb derselben Datenbanktransaktion wie die Personalaktion gespeichert. Der Versand erfolgt anschließend; ein Fehler macht den gespeicherten Antrag nicht rückgängig. Ausstehende Mails erscheinen im Personalbereich mit **Versand erneut versuchen**. Admins dürfen die Aufträge ihrer Kita wiederholen, Fachkräfte nur eigene. Pro Aktion werden bis zu 50 ausstehende Aufträge verarbeitet. Es gibt keinen zusätzlichen Hintergrundjob.
|
||||
|
||||
Vor jedem Versand werden Kita-Zugehörigkeit, aktuelle Rolle, Accountverknüpfung und gegebenenfalls die Abschalteinstellung geprüft. Ersetzte, abgelaufene oder bereits verwendete Aktivierungslinks werden übersprungen. Eine fünfminütige Versandsperre verhindert parallele Versuche für denselben Auftrag; ein stabiler Resend-Idempotenzschlüssel vermindert doppelte Zustellungen bei Wiederholungen. Eine Zustellbestätigung des Empfängerpostfachs wird nicht erfasst.
|
||||
|
||||
Benötigt werden `RESEND_API_KEY`, `EMAIL_FROM` und eine öffentliche App-Adresse über `NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL` oder `NEXTAUTH_URL`. Ohne Konfiguration bleibt ein Produktionsversand ausstehend und wird als Warnung angezeigt. Im Entwicklungsmodus ohne Resend-Schlüssel wird der bestehende lokale Mailtransport simuliert. Die Tests verwenden ausschließlich einen eingesetzten Testtransport; es wurden keine echten E-Mails verschickt.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Elternabläufe: Mitteilungen und Teilnahmeantworten
|
||||
|
||||
## Verhalten
|
||||
|
||||
- Die Übersicht zeigt offene Rückmeldungen und drei aktuelle Mitteilungen. Das Archiv unter `/dashboard/mitteilungen` enthält alle für das eigene Konto sichtbaren Meldungen, zwölf pro Seite.
|
||||
- Termine können eine Antwort pro Kind oder pro Familie verlangen. Familienzusagen brauchen mindestens einen teilnehmenden Erwachsenen; Absagen speichern 0 Erwachsene. Beide Elternkonten bearbeiten dieselbe Antwort.
|
||||
- Die Terminart schlägt bei neuen Terminen eine Antwortart vor. Die Verwaltung kann sie ändern, solange keine Antworten vorliegen. Danach verhindert auch die Server Action einen Wechsel. Normale Terminänderungen erhalten die bisherigen Teilnahmefelder.
|
||||
- Kinderantworten werden für alle eigenen aktiven Kinder gemeinsam in einer Transaktion gespeichert. Eigene und fremde Kinder dürfen nicht gemischt werden. Frist, Terminstatus, Haushalt, Kita und aktiviertes Terminmodul werden serverseitig geprüft.
|
||||
- Ohne eigene Rückmeldefrist gilt der Terminbeginn. Nach Fristablauf bleiben die Antworten im Kalender lesbar. Die Eingabe ist in Monatsansicht, Liste und Agenda erreichbar.
|
||||
- Empfängergruppen sind Eltern, ErzieherInnen oder beide. Die Gruppe steuert App-Anzeige, Lesestatus-Aktion, Anhänge und optionalen E-Mail-Versand. Admins können sämtliche Meldungen der eigenen Kita zur Verwaltung sehen. Eltern mit Koordinationsrolle gehören mit ihrem Haushalt zur Elterngruppe; Erzieherkonten gehören zur Erziehergruppe.
|
||||
- Teilnehmerhinweise werden nur bei einer Zusage des Haushalts oder für Admin/Koordination an den Kalender-Client übertragen.
|
||||
|
||||
## Bestehende Daten und Schemaaktualisierung
|
||||
|
||||
Das Projekt verwendet weiterhin `prisma db push`; es besitzt keine Prisma-Migrationshistorie. Der vorhandene Produktionsstart aktualisiert das Schema automatisch. Die neuen Pflichtfelder haben kompatible Defaults: bisherige Termine bleiben `CHILD`, bisherige Meldungen erhalten `ALLE`. Bestehende Kinderantworten werden nicht verändert.
|
||||
|
||||
Für eine kontrollierte, einmalige Aktualisierung einer Datenbank mit dem bisherigen Schema liegt zusätzlich die additive SQL-Datei `prisma/updates/20261007_parent_flows.sql` vor. Sie ist eine Alternative zum ersten `db push`, kein nachträglich anzuwendender zweiter Schritt. Beispiel:
|
||||
|
||||
```sh
|
||||
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction -f prisma/updates/20261007_parent_flows.sql
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
Die Datei gehört bewusst nicht in `prisma/migrations`: Eine Einführung von `prisma migrate deploy` würde zuerst eine Baseline für bereits existierende Datenbanken verlangen.
|
||||
|
||||
## Verifikation
|
||||
|
||||
```sh
|
||||
npm run test:parent-flows
|
||||
TEST_DATABASE_URL=postgresql://USER@127.0.0.1:PORT/TESTDB npm run test:parent-flows:integration
|
||||
npx tsc --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die Integrationstests benötigen eine Node.js-Version mit experimentellem Modul-Mocking (hier geprüft mit Node.js 26), eine ausdrücklich über `TEST_DATABASE_URL` gesetzte lokale PostgreSQL-Testdatenbank und das aktuelle Schema. Sie legen eigene Kitas mit zufälligen IDs an und entfernen ausschließlich diese Testkitas wieder. Kein automatischer Zugriff auf `DATABASE_URL` ohne Testfreigabe.
|
||||
|
||||
Die Tests prüfen Erwachsenenanzahl, gemischte Kinderantworten, gemeinsame Familienantworten, Fristablauf, Terminabsage, Moduldeaktivierung, Kita-/Haushaltsgrenzen, gesperrte Antwortart einschließlich paralleler erster Rückmeldung, erhaltene Terminoptionen sowie verweigerte Team-Meldungen und Anhänge für Eltern. E-Mail-Empfänger werden anhand derselben Gruppenregeln geprüft; es werden keine echten E-Mails verschickt.
|
||||
|
||||
Zusätzlich im Browser geprüft: Familienzusage, unterschiedliche Antworten für zwei Kinder, verschwundene offene Hinweise, Lesen/Ändern der gemeinsamen Antwort aus einem zweiten Elternkonto, abgelaufene Eingabe, Kalender-Monats- und Agendaansicht, Lesestatus, Archivnavigation, Verwaltungsauswertung, gesperrte Antwortart und Eltern-/Erzieher-Empfängertrennung. Die Rückmeldeformulare und Übersicht wurden auch bei 390 Pixeln Breite visuell geprüft.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Live-Testplan mein-kitaplaner.de
|
||||
|
||||
## Kopfbereich
|
||||
|
||||
- Zielsystem: `https://mein-kitaplaner.de`
|
||||
- Testdatum: `2026-05-17`
|
||||
- Strategie: echte Testdaten auf Produktion verwenden und anschließend bereinigen
|
||||
- Testkennzeichnung: alle Testnamen und Test-E-Mails mit `Codex Live-Test` kennzeichnen
|
||||
- Statuslegende:
|
||||
- `[ ]` offen
|
||||
- `[x]` erledigt
|
||||
- `[!]` Blocker / Beobachtung
|
||||
|
||||
## Testdaten
|
||||
|
||||
- Gründer/Admin: `hanivan790@acanok.com`
|
||||
- Test-Kita: `Codex Live-Test Kita-Verein 20260517-2343`
|
||||
- Elternteil: `kitatester1233@mailaddress.de`
|
||||
- Passwort: `<TEST_PASSWORD>`
|
||||
|
||||
Keine echten Passwörter, Secrets, Session-Werte, Tokens oder Invite-Links in diese Datei schreiben.
|
||||
|
||||
## Core Journey
|
||||
|
||||
- [x] Landingpage erreichbar.
|
||||
- Erwartung: `https://mein-kitaplaner.de` lädt mit aktuellem Landingpage-Content.
|
||||
- [x] Demo-CTA führt zum Kontaktformular.
|
||||
- Erwartung: CTA öffnet `/#kontakt` und das Formular ist sichtbar.
|
||||
- [x] Kontaktformular versendet Bestätigungsmail.
|
||||
- Bestätigt: Testmail an `nolip18234@acanok.com` ist angekommen.
|
||||
- [x] Registrierung: neuer Gründer/Admin kann Account erstellen.
|
||||
- Testdaten: eindeutige Admin-Testmail und `<TEST_PASSWORD>`.
|
||||
- Erwartung: Account wird erstellt, keine Fehlermeldung.
|
||||
- [x] Redirect nach Registrierung führt zu `/onboarding`.
|
||||
- Erwartung: Gründer landet nach Registrierung direkt im Onboarding.
|
||||
- [x] Onboarding: Kita wird mit Standardmodulen angelegt.
|
||||
- Testdaten: `Codex Live-Test Kita-Verein YYYYMMDD-HHMM`.
|
||||
- Erwartung: Notdienst, Kalender und Adressbuch bleiben standardmäßig aktiv.
|
||||
- [x] Redirect nach Onboarding führt zu `/dashboard`.
|
||||
- Erwartung: Dashboard lädt ohne erneute Anmeldung.
|
||||
- [x] Dashboard zeigt Kita-Namen und Admin-Navigation.
|
||||
- Erwartung: Sidebar zeigt Test-Kita sowie Admin-Menüpunkte wie Familienverwaltung, Dienstplan, Schwarzes Brett und ErzieherInnen.
|
||||
- [x] Familienverwaltung: Familie mit Kind und Elternteil anlegen.
|
||||
- Testdaten: Familie, Elternteil und Kind mit `Codex Live-Test` kennzeichnen.
|
||||
- Erwartung: Familie erscheint in der Liste.
|
||||
- [ ] Einladung wird per E-Mail versendet.
|
||||
- Erwartung: Eltern-Testadresse erhält eine Einladung.
|
||||
- Erst als erledigt markieren, wenn die Mail im Postfach bestätigt wurde.
|
||||
- [ ] Invite-Link öffnet Aktivierungsseite.
|
||||
- Erwartung: Seite `/invite/[token]` zeigt personalisierte Einladung und Kita-Namen.
|
||||
- Invite-Link nicht in diese Datei schreiben.
|
||||
- [ ] Invite-Validierung: Passwort-Pflicht, Passwort-Mismatch, Datenschutz-Pflicht.
|
||||
- Erwartung: fehlende oder unterschiedliche Passwörter und fehlende Datenschutzerklärung erzeugen sichtbare Fehler.
|
||||
- [ ] Elternteil kann Einladung annehmen und wird eingeloggt.
|
||||
- Erwartung: Passwort setzen, Datenschutzerklärung akzeptieren, optional Adressbuch-Opt-in wählen, danach Redirect zu `/dashboard`.
|
||||
- [ ] Elternrolle sieht nur erlaubte Navigation.
|
||||
- Erwartung: Eltern sehen keine Admin-only Punkte wie Familienverwaltung, Dienstplan, Schwarzes Brett-Admin, ErzieherInnen.
|
||||
- [ ] Logout/Login für Admin und Elternteil funktionieren.
|
||||
- Erwartung: beide Testaccounts können sich abmelden und mit `<TEST_PASSWORD>` erneut anmelden.
|
||||
- [x] Zugriffsschutz: `/dashboard` ohne Session leitet zu `/login`.
|
||||
- Erwartung: ausgeloggter Aufruf von `/dashboard` führt zu `/login?callbackUrl=%2Fdashboard` oder entsprechender Login-Route.
|
||||
- [ ] Cleanup: Test-Kita/User/Familie über Superadmin löschen.
|
||||
- Erwartung: Test-Kita wird über Superadmin-Diagnose mit exakter Namensbestätigung gelöscht.
|
||||
- Erst als erledigt markieren, wenn Superadmin-Ansicht keine Test-Kita/Test-User mehr zeigt.
|
||||
- [ ] Nach Cleanup: Test-Login funktioniert nicht mehr / Daten nicht sichtbar.
|
||||
- Erwartung: Admin- und Eltern-Testlogin schlagen fehl; Test-Kita erscheint nicht mehr.
|
||||
|
||||
## Beobachtungen
|
||||
|
||||
- [x] `2026-05-17`: Kontaktformular auf Produktion getestet. Bestätigungsmail an `nolip18234@acanok.com` ist angekommen.
|
||||
- [x] `2026-05-17`: Registrierung mit `hanivan790@acanok.com`, Redirect nach `/onboarding`, Onboarding mit `Codex Live-Test Kita-Verein 20260517-2343` und Dashboard/Admin-Navigation erfolgreich geprüft.
|
||||
- [!] `2026-05-17`: Familienanlage mit Elternadresse `nolip18234@acanok.com` blockiert, weil die E-Mail-Adresse bereits existiert. Mit `kitatester1233@mailaddress.de` erfolgreich fortgesetzt.
|
||||
- [x] `2026-05-17`: Admin-Logout, Zugriffsschutz für `/dashboard` ohne Session und Admin-Login mit temporärem Passwort erfolgreich geprüft. Kombinierter Login-Check bleibt offen, bis ein Elternaccount aktiviert werden kann.
|
||||
- [x] `2026-05-17`: Familie `Codex Live-Test Familie 20260517-2343` mit Kind und Elternteil `kitatester1233@mailaddress.de` erfolgreich angelegt; Status in der Familienverwaltung: `Einladung offen`.
|
||||
- [ ] `2026-05-17`: Im Familien-Details-Dialog ist kein Invite-Link sichtbar; Fortsetzung der Invite-Tests benötigt Mailbestätigung und Invite-Link aus dem Postfach.
|
||||
- [ ] Mailzeit Einladung Elternteil: offen.
|
||||
- [ ] Cleanup-Ergebnis: offen.
|
||||
|
||||
## Pflege-Regeln
|
||||
|
||||
- Erledigte Schritte während des Tests direkt von `[ ]` auf `[x]` setzen.
|
||||
- Blocker mit `[!]` markieren und kurz im Abschnitt Beobachtungen beschreiben.
|
||||
- Mailversand nur als erledigt markieren, wenn die Mail im jeweiligen Postfach bestätigt wurde.
|
||||
- Keine echten Passwörter, Secrets, Tokens oder vollständigen Invite-Links dokumentieren.
|
||||
- Cleanup erst als erledigt markieren, wenn die Testdaten in der Superadmin-Ansicht nicht mehr sichtbar sind.
|
||||
+9
-1
@@ -1,7 +1,15 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
pathname: "/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+19
@@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@react-email/render": "^2.0.8",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2973,6 +2974,24 @@
|
||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-email/render": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.8.tgz",
|
||||
"integrity": "sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"html-to-text": "^9.0.5",
|
||||
"prettier": "^3.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-email/row": {
|
||||
"version": "0.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
|
||||
|
||||
+7
-1
@@ -9,11 +9,16 @@
|
||||
"start": "next start",
|
||||
"start:prod": "next start",
|
||||
"lint": "eslint",
|
||||
"test:staff": "tsx --test tests/staff-calculations.test.ts",
|
||||
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
|
||||
"test:alarms": "tsx --test tests/notdienst-alarm.test.ts",
|
||||
"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"
|
||||
"db:seed:reset": "NODE_ENV=development prisma db seed -- --reset",
|
||||
"test:parent-flows": "tsx --test tests/parent-flows.test.ts",
|
||||
"test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
@@ -30,6 +35,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@react-email/render": "^2.0.8",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
+526
-18
@@ -63,6 +63,27 @@ enum TerminStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum RsvpMode {
|
||||
CHILD
|
||||
FAMILY
|
||||
}
|
||||
|
||||
enum AnnouncementAudience {
|
||||
ELTERN
|
||||
ERZIEHER
|
||||
ALLE
|
||||
}
|
||||
|
||||
enum EventParticipationStatus {
|
||||
ATTENDING
|
||||
NOT_ATTENDING
|
||||
}
|
||||
|
||||
enum AuditTargetType {
|
||||
KITA
|
||||
USER
|
||||
}
|
||||
|
||||
enum NotdienstPlanStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
@@ -74,6 +95,22 @@ enum NotdienstAlertStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum NotdienstAlarmStatus {
|
||||
PENDING
|
||||
CONFIRMED
|
||||
DECLINED
|
||||
LATE_CONFIRMATION
|
||||
REVIEW_REQUIRED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum NotdienstSwapStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
DECLINED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum DutyAssignmentStatus {
|
||||
PLANNED
|
||||
DONE
|
||||
@@ -121,10 +158,16 @@ model Kita {
|
||||
invitations Invitation[]
|
||||
termine Termin[]
|
||||
mitbringselItems MitbringselItem[]
|
||||
eventParticipations EventParticipation[]
|
||||
notdienstPlans NotdienstPlan[]
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAssignments NotdienstAssignment[]
|
||||
notdienstAlerts NotdienstAlert[]
|
||||
notdienstAlarms NotdienstAlarm[]
|
||||
notdienstSwapRequests NotdienstSwapRequest[]
|
||||
staffProfiles StaffProfile[]
|
||||
staffHolidays StaffHoliday[]
|
||||
familyEventParticipations FamilyEventParticipation[]
|
||||
|
||||
@@map("kitas")
|
||||
}
|
||||
@@ -139,14 +182,17 @@ model Family {
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
name String
|
||||
notdienstAlarms NotdienstAlarm[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
children Child[]
|
||||
dutyAssignments DutyAssignment[]
|
||||
users User[]
|
||||
children Child[]
|
||||
dutyAssignments DutyAssignment[]
|
||||
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
|
||||
familyEventParticipations FamilyEventParticipation[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("families")
|
||||
@@ -191,6 +237,7 @@ model User {
|
||||
privacyPolicyVersion String?
|
||||
/// Zeitstempel des Opt-Ins für das interne Kita-Adressbuch (Modul 3).
|
||||
directoryOptInAt DateTime?
|
||||
staffDecisionEmails Boolean @default(true)
|
||||
|
||||
// Auth-Bookkeeping
|
||||
emailVerifiedAt DateTime?
|
||||
@@ -204,18 +251,27 @@ model User {
|
||||
// Relations
|
||||
dutyAssignments ParentDutyAssignment[]
|
||||
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
|
||||
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
|
||||
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
|
||||
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
|
||||
announcementReads AnnouncementRead[]
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
|
||||
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
|
||||
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
|
||||
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester")
|
||||
notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder")
|
||||
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
|
||||
announcementReads AnnouncementRead[]
|
||||
|
||||
termineCreated Termin[] @relation("TerminCreator")
|
||||
termineApproved Termin[] @relation("TerminApprover")
|
||||
|
||||
mitbringselItems MitbringselItem[]
|
||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
||||
staffProfile StaffProfile?
|
||||
staffNotifications StaffNotification[]
|
||||
staffEmails StaffEmail[]
|
||||
alarmRecipients NotdienstAlarmRecipient[]
|
||||
alarmNotifications NotdienstAlarmNotification[]
|
||||
alarmEmails NotdienstAlarmEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@ -248,6 +304,7 @@ model Child {
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAssignments NotdienstAssignment[]
|
||||
absences Absence[]
|
||||
eventParticipations EventParticipation[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@ -271,11 +328,237 @@ model Educator {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
notdienstAlerts NotdienstAlert[]
|
||||
staffProfile StaffProfile?
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("educators")
|
||||
}
|
||||
|
||||
// Personalbereich: Tage als UTC-Datum, Zeiten als Minuten, Anspruch in halben Tagen.
|
||||
enum StaffRequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCEL_REQUESTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum StaffLeaveKind {
|
||||
VACATION
|
||||
TIME_OFF
|
||||
SICKNESS
|
||||
}
|
||||
|
||||
enum StaffWeekStatus {
|
||||
DRAFT
|
||||
SUBMITTED
|
||||
APPROVED
|
||||
}
|
||||
|
||||
model StaffProfile {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
educatorId String @unique
|
||||
educator Educator @relation(fields: [educatorId], references: [id], onDelete: NoAction)
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
employmentStart DateTime @db.Date
|
||||
employmentEnd DateTime? @db.Date
|
||||
trackingStart DateTime @db.Date
|
||||
openingMinutes Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
workPatterns StaffWorkPattern[]
|
||||
dayOverrides StaffDayOverride[]
|
||||
leaveAccounts StaffLeaveAccount[]
|
||||
leaves StaffLeave[]
|
||||
timeEntries StaffTimeEntry[]
|
||||
weeks StaffWeek[]
|
||||
adjustments StaffAdjustment[]
|
||||
history StaffHistory[]
|
||||
notifications StaffNotification[]
|
||||
emails StaffEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("staff_profiles")
|
||||
}
|
||||
|
||||
// Persisted in the same transaction as the staff action; delivered after commit.
|
||||
model StaffEmail {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
toAdmins Boolean @default(false)
|
||||
subject String
|
||||
message String
|
||||
path String
|
||||
inviteToken String?
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
attempts Int @default(0)
|
||||
failed Boolean @default(false)
|
||||
|
||||
@@index([profileId, sentAt, skippedAt])
|
||||
@@map("staff_emails")
|
||||
}
|
||||
|
||||
model StaffWorkPattern {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
effectiveFrom DateTime @db.Date
|
||||
// Montag bis Sonntag; 0 bedeutet arbeitsfrei.
|
||||
dailyMinutes Int[]
|
||||
|
||||
@@unique([profileId, effectiveFrom])
|
||||
@@map("staff_work_patterns")
|
||||
}
|
||||
|
||||
model StaffDayOverride {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
minutes Int
|
||||
reason String
|
||||
|
||||
@@unique([profileId, date])
|
||||
@@map("staff_day_overrides")
|
||||
}
|
||||
|
||||
model StaffHoliday {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
name String
|
||||
|
||||
@@unique([kitaId, date])
|
||||
@@map("staff_holidays")
|
||||
}
|
||||
|
||||
model StaffLeaveAccount {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
year Int
|
||||
entitlementUnits Int
|
||||
carryUnits Int @default(0)
|
||||
|
||||
@@unique([profileId, year])
|
||||
@@map("staff_leave_accounts")
|
||||
}
|
||||
|
||||
model StaffLeave {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
kind StaffLeaveKind
|
||||
status StaffRequestStatus @default(PENDING)
|
||||
startDate DateTime @db.Date
|
||||
endDate DateTime @db.Date
|
||||
halfDay Boolean @default(false)
|
||||
minutes Int?
|
||||
note String?
|
||||
decisionNote String?
|
||||
requestedById String
|
||||
reviewedById String?
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
days StaffLeaveDay[]
|
||||
|
||||
@@index([profileId, status, startDate])
|
||||
@@map("staff_leaves")
|
||||
}
|
||||
|
||||
model StaffLeaveDay {
|
||||
id String @id @default(cuid())
|
||||
leaveId String
|
||||
leave StaffLeave @relation(fields: [leaveId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
vacationUnits Int @default(0)
|
||||
creditMinutes Int
|
||||
|
||||
@@unique([leaveId, date])
|
||||
@@map("staff_leave_days")
|
||||
}
|
||||
|
||||
model StaffTimeEntry {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
startMinute Int
|
||||
endMinute Int
|
||||
breakMinutes Int @default(0)
|
||||
note String?
|
||||
|
||||
@@index([profileId, date])
|
||||
@@map("staff_time_entries")
|
||||
}
|
||||
|
||||
model StaffWeek {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
weekStart DateTime @db.Date
|
||||
status StaffWeekStatus @default(DRAFT)
|
||||
submittedAt DateTime?
|
||||
reviewedAt DateTime?
|
||||
reviewedById String?
|
||||
decisionNote String?
|
||||
// Snapshot von Soll-, Ist- und Abwesenheitsminuten bei Genehmigung.
|
||||
dailySnapshot Json?
|
||||
|
||||
@@unique([profileId, weekStart])
|
||||
@@map("staff_weeks")
|
||||
}
|
||||
|
||||
model StaffAdjustment {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
minutes Int
|
||||
reason String
|
||||
actorId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([profileId, date])
|
||||
@@map("staff_adjustments")
|
||||
}
|
||||
|
||||
model StaffHistory {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
actorId String
|
||||
action String
|
||||
details String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([profileId, createdAt])
|
||||
@@map("staff_history")
|
||||
}
|
||||
|
||||
model StaffNotification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
message String
|
||||
readAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, readAt, createdAt])
|
||||
@@map("staff_notifications")
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// PARENT DUTIES (Feste Ämter / Elterndienste, Modul 3)
|
||||
// =====================================================================
|
||||
@@ -398,8 +681,9 @@ model Announcement {
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
title String
|
||||
content String
|
||||
title String
|
||||
content String
|
||||
audience AnnouncementAudience @default(ALLE)
|
||||
|
||||
authorId String
|
||||
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||
@@ -490,6 +774,12 @@ model Termin {
|
||||
/// Mitbringliste pro Termin aktivierbar (Modul 2, "Flexible Mitbring-Listen")
|
||||
mitbringselListEnabled Boolean @default(false)
|
||||
|
||||
/// Teilnahme-Abfrage für Ausflüge und besondere Events.
|
||||
requiresRsvp Boolean @default(false)
|
||||
rsvpMode RsvpMode @default(CHILD)
|
||||
rsvpDeadline DateTime?
|
||||
participantInfo String?
|
||||
|
||||
createdById String?
|
||||
createdBy User? @relation("TerminCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -501,13 +791,56 @@ model Termin {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mitbringselItems MitbringselItem[]
|
||||
mitbringselItems MitbringselItem[]
|
||||
participations EventParticipation[]
|
||||
familyParticipations FamilyEventParticipation[]
|
||||
|
||||
@@index([kitaId, startDate])
|
||||
@@index([kitaId, status])
|
||||
@@map("termine")
|
||||
}
|
||||
|
||||
model EventParticipation {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
eventId String
|
||||
event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
childId String
|
||||
child Child @relation(fields: [childId], references: [id], onDelete: Cascade)
|
||||
|
||||
status EventParticipationStatus
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([eventId, childId])
|
||||
@@index([kitaId])
|
||||
@@index([childId])
|
||||
@@map("event_participations")
|
||||
}
|
||||
|
||||
model FamilyEventParticipation {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
eventId String
|
||||
event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
status EventParticipationStatus
|
||||
adultCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([eventId, familyId])
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@map("family_event_participations")
|
||||
}
|
||||
|
||||
model MitbringselItem {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
@@ -605,20 +938,165 @@ model NotdienstAssignment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
alerts NotdienstAlert[]
|
||||
alerts NotdienstAlert[]
|
||||
alarms NotdienstAlarm[]
|
||||
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
|
||||
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
|
||||
|
||||
@@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 {
|
||||
/// Tauschanfrage zwischen Eltern fuer zwei bereits veroeffentlichte
|
||||
/// zukuenftige Notdienst-Zuteilungen.
|
||||
model NotdienstSwapRequest {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
requesterUserId String
|
||||
requesterUser User @relation("NotdienstSwapRequester", fields: [requesterUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
requesterFamilyId String
|
||||
requesterFamily Family @relation("NotdienstSwapRequesterFamily", fields: [requesterFamilyId], references: [id], onDelete: Cascade)
|
||||
|
||||
targetAssignmentId String
|
||||
targetAssignment NotdienstAssignment @relation("NotdienstSwapTargetAssignment", fields: [targetAssignmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
offeredAssignmentId String
|
||||
offeredAssignment NotdienstAssignment @relation("NotdienstSwapOfferedAssignment", fields: [offeredAssignmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
status NotdienstSwapStatus @default(PENDING)
|
||||
message String?
|
||||
|
||||
respondedById String?
|
||||
respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull)
|
||||
respondedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([kitaId, status])
|
||||
@@index([requesterFamilyId, status])
|
||||
@@index([targetAssignmentId, status])
|
||||
@@index([offeredAssignmentId, status])
|
||||
@@map("notdienst_swap_requests")
|
||||
}
|
||||
|
||||
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
|
||||
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
|
||||
model NotdienstAlarm {
|
||||
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: SetNull)
|
||||
// Nullable unique reservation: cancelled or ended alarms release the assignment.
|
||||
activeAssignmentId String? @unique
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
familyName String
|
||||
childName String
|
||||
date DateTime @db.Date
|
||||
wholeDay Boolean @default(true)
|
||||
startMinute Int?
|
||||
endMinute Int?
|
||||
expiresAt DateTime
|
||||
deadline DateTime
|
||||
status NotdienstAlarmStatus @default(PENDING)
|
||||
triggeredById String?
|
||||
triggeredAt DateTime @default(now())
|
||||
respondedById String?
|
||||
respondedAt DateTime?
|
||||
responseSource String?
|
||||
escalatedAt DateTime?
|
||||
educatorId String?
|
||||
notes String?
|
||||
cancelledAt DateTime?
|
||||
cancelReason String?
|
||||
recipients NotdienstAlarmRecipient[]
|
||||
notifications NotdienstAlarmNotification[]
|
||||
emails NotdienstAlarmEmail[]
|
||||
history NotdienstAlarmHistory[]
|
||||
legacyAlerts NotdienstAlert[]
|
||||
|
||||
@@index([kitaId, date])
|
||||
@@index([status, deadline])
|
||||
@@map("notdienst_alarms")
|
||||
}
|
||||
|
||||
model NotdienstAlarmRecipient {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
|
||||
@@unique([alarmId, userId])
|
||||
@@map("notdienst_alarm_recipients")
|
||||
}
|
||||
|
||||
model NotdienstAlarmNotification {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
eventKey String
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
readAt DateTime?
|
||||
|
||||
@@unique([alarmId, userId, eventKey])
|
||||
@@index([userId, readAt])
|
||||
@@map("notdienst_alarm_notifications")
|
||||
}
|
||||
|
||||
model NotdienstAlarmEmail {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
eventKey String
|
||||
kind String
|
||||
subject String
|
||||
message String
|
||||
path String
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
failed Boolean @default(false)
|
||||
attempts Int @default(0)
|
||||
|
||||
@@unique([alarmId, userId, eventKey])
|
||||
@@index([sentAt, skippedAt, createdAt])
|
||||
@@map("notdienst_alarm_emails")
|
||||
}
|
||||
|
||||
model NotdienstAlarmHistory {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
actorId String?
|
||||
action String
|
||||
details String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([alarmId, createdAt])
|
||||
@@map("notdienst_alarm_history")
|
||||
}
|
||||
|
||||
model NotdienstAlert {
|
||||
id String @id @default(cuid())
|
||||
alarmId String?
|
||||
alarm NotdienstAlarm? @relation(fields: [alarmId], references: [id], onDelete: SetNull)
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
assignmentId String
|
||||
assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -652,6 +1130,36 @@ model NotdienstAlert {
|
||||
@@map("notdienst_alerts")
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// AUDIT LOGS (Systemweite Superadmin-Aktionen)
|
||||
// =====================================================================
|
||||
|
||||
/// Persistente Spur fuer Superadmin-Support-Aktionen.
|
||||
/// Zielobjekte werden bewusst nur als Snapshot gespeichert, damit Logs auch
|
||||
/// nach einer Kita- oder Benutzerloeschung erhalten bleiben.
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
|
||||
actorUserId String?
|
||||
actorUser User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
actorEmail String
|
||||
|
||||
action String
|
||||
targetType AuditTargetType
|
||||
targetId String
|
||||
targetLabel String
|
||||
targetKitaId String?
|
||||
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([actorUserId])
|
||||
@@index([targetType, targetId])
|
||||
@@index([targetKitaId])
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// AUTH-HILFSTABELLE
|
||||
// =====================================================================
|
||||
|
||||
+355
-23
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
AbsenceReason,
|
||||
AuditTargetType,
|
||||
EventParticipationStatus,
|
||||
InvitationStatus,
|
||||
NotdienstAlertStatus,
|
||||
NotdienstPlanStatus,
|
||||
@@ -35,6 +37,9 @@ const DEMO_USER_EMAILS = [
|
||||
"schmidt@waldameisen.local",
|
||||
"yilmaz@waldameisen.local",
|
||||
"pending@waldameisen.local",
|
||||
"fischer@waldameisen.local",
|
||||
"kowalski@waldameisen.local",
|
||||
"weber@waldameisen.local",
|
||||
];
|
||||
|
||||
const DEMO_VERIFICATION_TOKENS = [
|
||||
@@ -293,11 +298,87 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
||||
},
|
||||
});
|
||||
|
||||
const familyKowalski = await prisma.family.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
name: "Familie Kowalski",
|
||||
},
|
||||
});
|
||||
|
||||
const familyWeber = await prisma.family.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
name: "Familie Weber",
|
||||
},
|
||||
});
|
||||
|
||||
const elternFischer = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyFischer.id,
|
||||
email: "fischer@waldameisen.local",
|
||||
passwordHash,
|
||||
firstName: "Frank",
|
||||
lastName: "Fischer",
|
||||
role: UserRole.ELTERN,
|
||||
privacyPolicyAcceptedAt: consentAt,
|
||||
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
directoryOptInAt: consentAt,
|
||||
emailVerifiedAt: consentAt,
|
||||
phone: "+49 30 5555 1234",
|
||||
street: "Fischerweg 4",
|
||||
postalCode: "10247",
|
||||
city: "Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
const elternKowalski = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyKowalski.id,
|
||||
email: "kowalski@waldameisen.local",
|
||||
passwordHash,
|
||||
firstName: "Jan",
|
||||
lastName: "Kowalski",
|
||||
role: UserRole.ELTERN,
|
||||
privacyPolicyAcceptedAt: consentAt,
|
||||
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
directoryOptInAt: consentAt,
|
||||
emailVerifiedAt: consentAt,
|
||||
phone: "+49 30 5555 5678",
|
||||
street: "Grenzallee 12",
|
||||
postalCode: "12057",
|
||||
city: "Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
const elternWeber = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyWeber.id,
|
||||
email: "weber@waldameisen.local",
|
||||
passwordHash,
|
||||
firstName: "Sabine",
|
||||
lastName: "Weber",
|
||||
role: UserRole.ELTERN,
|
||||
privacyPolicyAcceptedAt: consentAt,
|
||||
privacyPolicyVersion: PRIVACY_POLICY_VERSION,
|
||||
directoryOptInAt: consentAt,
|
||||
emailVerifiedAt: consentAt,
|
||||
phone: "+49 30 5555 9012",
|
||||
street: "Weberstrasse 7",
|
||||
postalCode: "10249",
|
||||
city: "Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
kita,
|
||||
familyMueller,
|
||||
familySchmidtYilmaz,
|
||||
familyFischer,
|
||||
familyKowalski,
|
||||
familyWeber,
|
||||
superAdmin,
|
||||
admin,
|
||||
erzieherUser,
|
||||
@@ -305,6 +386,9 @@ async function createDemoUsers(passwordHash: string, consentAt: Date) {
|
||||
elternSchmidt,
|
||||
elternYilmaz,
|
||||
pendingParent,
|
||||
elternFischer,
|
||||
elternKowalski,
|
||||
elternWeber,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +397,8 @@ async function createChildren({
|
||||
familyMueller,
|
||||
familySchmidtYilmaz,
|
||||
familyFischer,
|
||||
familyKowalski,
|
||||
familyWeber,
|
||||
}: SeedContext) {
|
||||
const anna = await prisma.child.create({
|
||||
data: {
|
||||
@@ -365,7 +451,27 @@ async function createChildren({
|
||||
},
|
||||
});
|
||||
|
||||
return { anna, ben, clara, emil, nina };
|
||||
const leo = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyKowalski.id,
|
||||
firstName: "Leo",
|
||||
lastName: "Kowalski",
|
||||
dateOfBirth: new Date("2022-08-12"),
|
||||
},
|
||||
});
|
||||
|
||||
const mia = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: familyWeber.id,
|
||||
firstName: "Mia",
|
||||
lastName: "Weber",
|
||||
dateOfBirth: new Date("2021-05-18"),
|
||||
},
|
||||
});
|
||||
|
||||
return { anna, ben, clara, emil, nina, leo, mia };
|
||||
}
|
||||
|
||||
async function createEducators(kitaId: string) {
|
||||
@@ -530,11 +636,9 @@ async function createInvites({ kita, admin, pendingParent }: SeedContext) {
|
||||
});
|
||||
}
|
||||
|
||||
async function createAnnouncements({
|
||||
kita,
|
||||
admin,
|
||||
koordinator,
|
||||
}: SeedContext) {
|
||||
async function createAnnouncements(context: SeedContext) {
|
||||
const { kita, admin, koordinator, elternSchmidt, elternYilmaz, elternFischer } = context;
|
||||
|
||||
const sommerfest = await prisma.announcement.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
@@ -545,7 +649,7 @@ async function createAnnouncements({
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.announcement.create({
|
||||
const garderobe = await prisma.announcement.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
title: "Neue Garderoben-Regelung",
|
||||
@@ -555,21 +659,48 @@ async function createAnnouncements({
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.announcementRead.create({
|
||||
const longTitle = await prisma.announcement.create({
|
||||
data: {
|
||||
userId: koordinator.id,
|
||||
announcementId: sommerfest.id,
|
||||
kitaId: kita.id,
|
||||
title: "Dies ist eine extrem lange Ankuendigung mit einem superlangen Titel um das Layout des Dashboards und des Schwarzen Bretts auf korrekten Textumbruch und Mobilfreundlichkeit zu testen",
|
||||
content: "Inhalt der superlangen Ankuendigung fuer Layout-Testing.",
|
||||
authorId: admin.id,
|
||||
},
|
||||
});
|
||||
|
||||
const xssTitle = await prisma.announcement.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
title: "Boesartiger Titel mit <script>alert('XSS_NEWS_TITLE')</script> HTML Tags",
|
||||
content: "Dieser Beitrag testet, ob die Anwendung XSS-Angriffe im Titel oder im Inhalt filtert. <script>alert('XSS_NEWS_CONTENT')</script>",
|
||||
authorId: admin.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.attachment.create({
|
||||
data: {
|
||||
announcementId: sommerfest.id,
|
||||
fileName: "sommerfest_ablauf.pdf",
|
||||
fileUrl: "https://example.com/sommerfest_ablauf.pdf",
|
||||
fileType: "application/pdf",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.announcementRead.createMany({
|
||||
data: [
|
||||
{ userId: koordinator.id, announcementId: sommerfest.id },
|
||||
{ userId: elternSchmidt.id, announcementId: sommerfest.id },
|
||||
{ userId: elternYilmaz.id, announcementId: sommerfest.id },
|
||||
{ userId: elternFischer.id, announcementId: garderobe.id },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function createTermine({
|
||||
kita,
|
||||
admin,
|
||||
koordinator,
|
||||
elternSchmidt,
|
||||
elternYilmaz,
|
||||
}: SeedContext) {
|
||||
async function createTermine(
|
||||
context: SeedContext,
|
||||
children: Awaited<ReturnType<typeof createChildren>>,
|
||||
) {
|
||||
const { kita, admin, koordinator, elternSchmidt, elternYilmaz, elternFischer, elternKowalski } = context;
|
||||
const now = new Date();
|
||||
|
||||
const elternabend = await prisma.termin.create({
|
||||
@@ -636,6 +767,100 @@ async function createTermine({
|
||||
},
|
||||
});
|
||||
|
||||
const zooAusflug = await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
title: "Ausflug in den Zoo",
|
||||
description: "Gemeinsamer Ausflug der gesamten Kita-Gruppe in den Berliner Zoo.",
|
||||
type: TerminType.SONSTIGES,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
startDate: addDays(now, 15),
|
||||
endDate: addDays(now, 15),
|
||||
allDay: true,
|
||||
requiresRsvp: true,
|
||||
rsvpDeadline: addDays(now, 5),
|
||||
participantInfo: "Bitte wetterfeste Kleidung und Rucksack-Verpflegung mitbringen.",
|
||||
createdById: admin.id,
|
||||
approvedById: admin.id,
|
||||
approvedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventParticipation.createMany({
|
||||
data: [
|
||||
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.anna.id, status: EventParticipationStatus.ATTENDING },
|
||||
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.clara.id, status: EventParticipationStatus.ATTENDING },
|
||||
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.emil.id, status: EventParticipationStatus.NOT_ATTENDING },
|
||||
{ kitaId: kita.id, eventId: zooAusflug.id, childId: children.leo.id, status: EventParticipationStatus.ATTENDING },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
title: "Mitmach-Tag (Garten)",
|
||||
description: "Gemeinsames Herrichten des Gartens fuer die warme Jahreszeit. (Abgesagt wegen Dauerregen)",
|
||||
type: TerminType.MITMACH_TAG,
|
||||
status: TerminStatus.CANCELLED,
|
||||
startDate: addDays(now, 2),
|
||||
endDate: addDays(now, 2),
|
||||
allDay: false,
|
||||
createdById: admin.id,
|
||||
},
|
||||
});
|
||||
|
||||
const sommerfestEvent = await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
title: "Sommerfest Waldameisen",
|
||||
description: "Unser jaehrliches grosses Sommerfest im Kita-Garten mit Buffet und Spielstationen.",
|
||||
type: TerminType.KITA_FEST,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
startDate: addDays(now, 30),
|
||||
endDate: addDays(now, 30),
|
||||
allDay: false,
|
||||
mitbringselListEnabled: true,
|
||||
createdById: admin.id,
|
||||
approvedById: admin.id,
|
||||
approvedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const highDensityDay = addDays(now, 7);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
title: `Parallel-Termin #${i}`,
|
||||
description: `Beschreibung fuer Parallel-Termin #${i}`,
|
||||
type: TerminType.SONSTIGES,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
startDate: highDensityDay,
|
||||
endDate: highDensityDay,
|
||||
allDay: false,
|
||||
createdById: admin.id,
|
||||
approvedById: admin.id,
|
||||
approvedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
title: "Boesartiger Termin <script>alert('XSS_CAL_TITLE')</script>",
|
||||
description: "XSS Test im Terminkalender <script>alert('XSS_CAL_DESC')</script>",
|
||||
type: TerminType.SONSTIGES,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
startDate: addDays(now, 5),
|
||||
endDate: addDays(now, 5),
|
||||
allDay: true,
|
||||
createdById: admin.id,
|
||||
approvedById: admin.id,
|
||||
approvedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.mitbringselItem.createMany({
|
||||
data: [
|
||||
{
|
||||
@@ -650,15 +875,34 @@ async function createTermine({
|
||||
userId: elternSchmidt.id,
|
||||
content: "Brot und Aufstrich",
|
||||
},
|
||||
{
|
||||
kitaId: kita.id,
|
||||
terminId: sommerfestEvent.id,
|
||||
userId: elternYilmaz.id,
|
||||
content: "Nudelsalat (vegan)",
|
||||
},
|
||||
{
|
||||
kitaId: kita.id,
|
||||
terminId: sommerfestEvent.id,
|
||||
userId: elternFischer.id,
|
||||
content: "Mehrweg-Becher (ca. 30 Stk.)",
|
||||
},
|
||||
{
|
||||
kitaId: kita.id,
|
||||
terminId: sommerfestEvent.id,
|
||||
userId: elternKowalski.id,
|
||||
content: "Apfelsaft & Wasser",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function createNotdienstData(
|
||||
{ kita, koordinator, elternSchmidt, elternYilmaz }: SeedContext,
|
||||
context: SeedContext,
|
||||
children: Awaited<ReturnType<typeof createChildren>>,
|
||||
educators: Awaited<ReturnType<typeof createEducators>>,
|
||||
) {
|
||||
const { kita, koordinator, elternSchmidt, elternYilmaz, elternWeber, elternKowalski } = context;
|
||||
const today = dateOnly(new Date());
|
||||
const currentMonth = startOfMonth(today);
|
||||
const previousMonth = getPreviousMonth(today);
|
||||
@@ -670,6 +914,8 @@ async function createNotdienstData(
|
||||
{ childId: children.clara.id, userId: elternSchmidt.id },
|
||||
{ childId: children.emil.id, userId: elternYilmaz.id },
|
||||
{ childId: children.ben.id, userId: koordinator.id },
|
||||
{ childId: children.leo.id, userId: elternKowalski.id },
|
||||
{ childId: children.mia.id, userId: elternWeber.id },
|
||||
];
|
||||
|
||||
const targetWorkingDays = getWorkingDays(
|
||||
@@ -710,6 +956,30 @@ async function createNotdienstData(
|
||||
},
|
||||
});
|
||||
|
||||
const alertAssignmentDate = addDays(today, 3);
|
||||
const pendingAlertAssignment = await prisma.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
planId: currentPlan.id,
|
||||
childId: children.emil.id,
|
||||
date: alertAssignmentDate,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
assignmentId: pendingAlertAssignment.id,
|
||||
parentUserId: elternYilmaz.id,
|
||||
triggeredById: koordinator.id,
|
||||
educatorId: educators.petra.id,
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
confirmationToken: "demo-pending-alert-token",
|
||||
triggeredAt: today,
|
||||
notes: "Es fehlen zwei Kraefte. Bitte Notdienst besetzen.",
|
||||
},
|
||||
});
|
||||
|
||||
const previousPlan = await prisma.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
@@ -744,6 +1014,60 @@ async function createNotdienstData(
|
||||
notes: "Beispiel-Alarm aus Seed-Daten.",
|
||||
},
|
||||
});
|
||||
|
||||
const draftPlan = await prisma.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
year: targetMonth.getFullYear(),
|
||||
month: targetMonth.getMonth() + 1,
|
||||
status: NotdienstPlanStatus.DRAFT,
|
||||
createdById: koordinator.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createAuditLogs({ kita, superAdmin, admin }: SeedContext) {
|
||||
await prisma.auditLog.createMany({
|
||||
data: [
|
||||
{
|
||||
actorUserId: superAdmin.id,
|
||||
actorEmail: superAdmin.email,
|
||||
action: "KITA_CREATE",
|
||||
targetType: AuditTargetType.KITA,
|
||||
targetId: kita.id,
|
||||
targetLabel: kita.name,
|
||||
targetKitaId: kita.id,
|
||||
metadata: { name: kita.name, slug: kita.slug },
|
||||
createdAt: addDays(new Date(), -30),
|
||||
},
|
||||
{
|
||||
actorUserId: superAdmin.id,
|
||||
actorEmail: superAdmin.email,
|
||||
action: "USER_ROLE_UPDATE",
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: admin.id,
|
||||
targetLabel: admin.email,
|
||||
targetKitaId: kita.id,
|
||||
metadata: { oldRole: "ELTERN", newRole: "ADMIN" },
|
||||
createdAt: addDays(new Date(), -28),
|
||||
},
|
||||
{
|
||||
actorUserId: admin.id,
|
||||
actorEmail: admin.email,
|
||||
action: "KITA_MODULE_UPDATE",
|
||||
targetType: AuditTargetType.KITA,
|
||||
targetId: kita.id,
|
||||
targetLabel: kita.name,
|
||||
targetKitaId: kita.id,
|
||||
metadata: {
|
||||
notdienstModuleEnabled: true,
|
||||
terminModuleEnabled: true,
|
||||
adressbuchModuleEnabled: true,
|
||||
},
|
||||
createdAt: addDays(new Date(), -25),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function createDemoData() {
|
||||
@@ -760,8 +1084,9 @@ async function createDemoData() {
|
||||
await createAbsences(context, children);
|
||||
await createInvites(context);
|
||||
await createAnnouncements(context);
|
||||
await createTermine(context);
|
||||
await createTermine(context, children);
|
||||
await createNotdienstData(context, children, educators);
|
||||
await createAuditLogs(context);
|
||||
|
||||
printSummary(context, children);
|
||||
}
|
||||
@@ -776,7 +1101,10 @@ async function seedIfEmpty() {
|
||||
}
|
||||
|
||||
function printSummary(
|
||||
{
|
||||
context: SeedContext,
|
||||
children: Awaited<ReturnType<typeof createChildren>>,
|
||||
) {
|
||||
const {
|
||||
kita,
|
||||
superAdmin,
|
||||
admin,
|
||||
@@ -784,10 +1112,11 @@ function printSummary(
|
||||
koordinator,
|
||||
elternSchmidt,
|
||||
elternYilmaz,
|
||||
elternFischer,
|
||||
elternKowalski,
|
||||
elternWeber,
|
||||
pendingParent,
|
||||
}: SeedContext,
|
||||
children: Awaited<ReturnType<typeof createChildren>>,
|
||||
) {
|
||||
} = context;
|
||||
console.log("Seed complete");
|
||||
console.log("");
|
||||
console.log(` Kita: ${kita.name} (${kita.slug})`);
|
||||
@@ -799,6 +1128,9 @@ function printSummary(
|
||||
console.log(` Koordinator: ${koordinator.email}`);
|
||||
console.log(` Eltern: ${elternSchmidt.email}`);
|
||||
console.log(` Eltern: ${elternYilmaz.email}`);
|
||||
console.log(` Eltern (Fischer): ${elternFischer.email}`);
|
||||
console.log(` Eltern (Kowalski): ${elternKowalski.email}`);
|
||||
console.log(` Eltern (Weber): ${elternWeber.email}`);
|
||||
console.log("");
|
||||
console.log(
|
||||
` Offener Invite: ${pendingParent.email} -> /invite/demo-pending-parent-token`,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnnouncementAudience" AS ENUM ('ELTERN', 'ERZIEHER', 'ALLE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "announcements" ADD COLUMN "audience" "AnnouncementAudience" NOT NULL DEFAULT 'ALLE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "termine" ADD COLUMN "rsvpMode" "RsvpMode" NOT NULL DEFAULT 'CHILD';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "family_event_participations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"status" "EventParticipationStatus" NOT NULL,
|
||||
"adultCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "family_event_participations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "family_event_participations_kitaId_idx" ON "family_event_participations"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "family_event_participations_familyId_idx" ON "family_event_participations"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "family_event_participations_eventId_familyId_key" ON "family_event_participations"("eventId", "familyId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,42 @@
|
||||
// Runs inside the application container. No external cron service or browser is required.
|
||||
const secret = process.env.NOTDIENST_JOB_SECRET;
|
||||
if (!secret || secret.length < 32) {
|
||||
console.error(
|
||||
"Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1);
|
||||
let stopped = false;
|
||||
let timer;
|
||||
async function tick() {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/jobs/notdienstalarme`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${secret}` },
|
||||
signal: AbortSignal.timeout(50000),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
console.error(
|
||||
`Notdienst-Fristprüfung: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
|
||||
);
|
||||
} catch {
|
||||
console.error(
|
||||
"Notdienst-Fristprüfung momentan nicht erreichbar; erneuter Versuch in einer Minute.",
|
||||
);
|
||||
}
|
||||
if (!stopped)
|
||||
timer = setTimeout(tick, Math.max(1000, 60000 - (Date.now() - started)));
|
||||
}
|
||||
for (const signal of ["SIGINT", "SIGTERM"])
|
||||
process.on(signal, () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
process.exit(0);
|
||||
});
|
||||
timer = setTimeout(tick, 5000);
|
||||
@@ -5,4 +5,13 @@ echo "Applying Prisma schema..."
|
||||
npx prisma db push --skip-generate
|
||||
|
||||
echo "Starting Kita-Planer..."
|
||||
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
|
||||
if [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
|
||||
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/notdienst-alarm-worker.mjs &
|
||||
else
|
||||
echo "Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET fehlt."
|
||||
fi
|
||||
exec npm run start:prod
|
||||
|
||||
@@ -5,13 +5,17 @@ import { mkdir, rm, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { AnnouncementAudience, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { NewsEmail } from "@/emails/NewsEmail";
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
announcementRecipientWhere,
|
||||
visibleAnnouncementAudiences,
|
||||
} from "@/lib/announcement-access";
|
||||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = new Map([
|
||||
@@ -55,25 +59,15 @@ function validateAttachment(file: File) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
||||
const where: Prisma.UserWhereInput =
|
||||
audience === "ELTERN"
|
||||
? {
|
||||
kitaId,
|
||||
familyId: { not: null },
|
||||
emailVerifiedAt: { not: null },
|
||||
}
|
||||
: audience === "ERZIEHER"
|
||||
? {
|
||||
kitaId,
|
||||
role: UserRole.ERZIEHER,
|
||||
emailVerifiedAt: { not: null },
|
||||
}
|
||||
: {
|
||||
kitaId,
|
||||
emailVerifiedAt: { not: null },
|
||||
OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }],
|
||||
};
|
||||
async function getRecipients(
|
||||
kitaId: string,
|
||||
audience: z.infer<typeof audienceSchema>,
|
||||
) {
|
||||
const where = {
|
||||
kitaId,
|
||||
emailVerifiedAt: { not: null },
|
||||
...announcementRecipientWhere(audience as AnnouncementAudience),
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
@@ -129,6 +123,7 @@ export async function createAnnouncement(formData: FormData) {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
audience: parsed.data.audience,
|
||||
authorId: session.user.id,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
@@ -166,7 +161,10 @@ export async function createAnnouncement(formData: FormData) {
|
||||
rm(path.join(uploadDir, attachment.id), { force: true }),
|
||||
),
|
||||
);
|
||||
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
|
||||
console.error(
|
||||
"Ankündigungs-Anhang konnte nicht gespeichert werden:",
|
||||
error,
|
||||
);
|
||||
return { error: "Anhang konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
@@ -198,6 +196,7 @@ export async function createAnnouncement(formData: FormData) {
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/admin/news");
|
||||
revalidatePath("/dashboard/mitteilungen");
|
||||
|
||||
if (emailError) {
|
||||
return {
|
||||
@@ -216,6 +215,7 @@ export async function markAnnouncementRead(announcementId: string) {
|
||||
where: {
|
||||
id: announcementId,
|
||||
kitaId: session.user.kitaId,
|
||||
audience: { in: visibleAnnouncementAudiences(session.user) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -241,5 +241,6 @@ export async function markAnnouncementRead(announcementId: string) {
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/mitteilungen");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
+59
-11
@@ -1,20 +1,52 @@
|
||||
"use server";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
|
||||
import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
|
||||
import {
|
||||
contactRequestSchema,
|
||||
contactRequestTypeLabels,
|
||||
type ContactRequestInput,
|
||||
type ContactSubmissionPayload,
|
||||
} from "@/lib/contact-schema";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
|
||||
|
||||
async function resolveClientIp(): Promise<string> {
|
||||
const h = await headers();
|
||||
const forwarded = h.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
return h.get("x-real-ip") ?? "unknown";
|
||||
}
|
||||
|
||||
export async function submitContactRequest(data: ContactSubmissionPayload) {
|
||||
// Honeypot: silently absorb bot submissions without informing them.
|
||||
if (data.website && data.website.trim().length > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ip = await resolveClientIp();
|
||||
const limit = rateLimit(
|
||||
`contact:${ip}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_SECONDS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60));
|
||||
return {
|
||||
success: false,
|
||||
error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitContactRequest(data: ContactRequestInput) {
|
||||
const parsed = contactRequestSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -32,19 +64,35 @@ export async function submitContactRequest(data: ContactRequestInput) {
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL;
|
||||
const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType];
|
||||
const result = await sendAppEmail({
|
||||
to: adminEmail,
|
||||
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
|
||||
replyTo: parsed.data.email,
|
||||
react: createElement(ContactRequestEmail, parsed.data),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const [adminResult, confirmationResult] = await Promise.all([
|
||||
sendAppEmail({
|
||||
to: adminEmail,
|
||||
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
|
||||
replyTo: parsed.data.email,
|
||||
react: createElement(ContactRequestEmail, parsed.data),
|
||||
}),
|
||||
sendAppEmail({
|
||||
to: parsed.data.email,
|
||||
subject: "Wir haben deine Anfrage erhalten — Kita-Planer",
|
||||
replyTo: adminEmail,
|
||||
react: createElement(ContactConfirmationEmail, parsed.data),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!adminResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
error: adminResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (!confirmationResult.success) {
|
||||
console.error(
|
||||
"Bestätigungs-E-Mail konnte nicht zugestellt werden:",
|
||||
confirmationResult.error,
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
EventParticipationStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
import { rsvpSubmissionSchema } from "@/lib/rsvp";
|
||||
import { lockEvent } from "@/lib/event-lock";
|
||||
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type EventParticipantDto = {
|
||||
childId: string;
|
||||
childName: string;
|
||||
familyName: string;
|
||||
status: EventParticipationStatus | null;
|
||||
};
|
||||
|
||||
export async function submitEventRsvp(
|
||||
rawPayload: unknown,
|
||||
): Promise<{ error?: string; success?: boolean }> {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = rsvpSubmissionSchema.safeParse(rawPayload);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error:
|
||||
"Bitte alle Antworten und bei Familienzusagen die Anzahl Erwachsener angeben.",
|
||||
};
|
||||
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER) {
|
||||
return {
|
||||
error: "Für Rückmeldungen ist ein Elternkonto mit Haushalt erforderlich.",
|
||||
};
|
||||
}
|
||||
const kitaId = session.user.kitaId;
|
||||
const familyId = session.user.familyId;
|
||||
const payload = parsed.data;
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await lockEvent(tx, payload.eventId, kitaId);
|
||||
const event = await tx.termin.findFirst({
|
||||
where: { id: payload.eventId, kitaId },
|
||||
select: {
|
||||
status: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
rsvpDeadline: true,
|
||||
startDate: true,
|
||||
kita: { select: { terminModuleEnabled: true } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!event?.kita.terminModuleEnabled ||
|
||||
!event.requiresRsvp ||
|
||||
event.status !== TerminStatus.CONFIRMED
|
||||
) {
|
||||
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
|
||||
}
|
||||
if (event.rsvpMode !== payload.mode)
|
||||
return {
|
||||
error: "Die Antwortart wurde geändert. Bitte lade den Termin erneut.",
|
||||
};
|
||||
if (payload.mode === RsvpMode.FAMILY) {
|
||||
const family = await tx.family.findFirst({
|
||||
where: { id: familyId, kitaId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!family) return { error: "Haushalt wurde nicht gefunden." };
|
||||
if (
|
||||
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
|
||||
event.startDate <= new Date()
|
||||
) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
await tx.familyEventParticipation.upsert({
|
||||
where: { eventId_familyId: { eventId: payload.eventId, familyId } },
|
||||
create: {
|
||||
kitaId,
|
||||
eventId: payload.eventId,
|
||||
familyId,
|
||||
status: payload.status,
|
||||
adultCount: payload.adultCount,
|
||||
},
|
||||
update: { status: payload.status, adultCount: payload.adultCount },
|
||||
});
|
||||
} else {
|
||||
const children = await tx.child.findMany({
|
||||
where: { kitaId, familyId, active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = new Set(children.map((child) => child.id));
|
||||
if (
|
||||
payload.responses.length !== ids.size ||
|
||||
payload.responses.some((row) => !ids.has(row.childId))
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"Bitte antworte für alle eigenen aktiven Kinder. Lade den Termin bei Änderungen erneut.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
|
||||
event.startDate <= new Date()
|
||||
) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
for (const response of payload.responses) {
|
||||
await tx.eventParticipation.upsert({
|
||||
where: {
|
||||
eventId_childId: {
|
||||
eventId: payload.eventId,
|
||||
childId: response.childId,
|
||||
},
|
||||
},
|
||||
create: { kitaId, eventId: payload.eventId, ...response },
|
||||
update: { status: response.status },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
if (result.error) return result;
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Rückmeldung konnte nicht gespeichert werden:", error);
|
||||
return {
|
||||
error:
|
||||
"Rückmeldung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEventParticipants(
|
||||
eventId: string,
|
||||
): Promise<{ participants?: EventParticipantDto[]; error?: string }> {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const event = await prisma.termin.findFirst({
|
||||
where: { id: eventId, kitaId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return { error: "Termin wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const children = await prisma.child.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
active: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
eventParticipations: {
|
||||
where: { eventId },
|
||||
select: { status: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ family: { name: "asc" } },
|
||||
{ lastName: "asc" },
|
||||
{ firstName: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
participants: children.map((child) => ({
|
||||
childId: child.id,
|
||||
childName: `${child.firstName} ${child.lastName}`,
|
||||
familyName: child.family.name,
|
||||
status: child.eventParticipations[0]?.status ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Activity, Building2, ScrollText } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const items = [
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Kitas",
|
||||
icon: Building2,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
href: "/admin/audit",
|
||||
label: "AuditLog",
|
||||
icon: ScrollText,
|
||||
exact: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-1 px-3">
|
||||
{items.map(({ href, label, icon: Icon, exact }) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-3 border-t px-3 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Diagnose
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground">
|
||||
<Activity className="h-4 w-4 shrink-0" />
|
||||
Read-only Support
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle, 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 { deleteKita } from "../actions";
|
||||
|
||||
type DeleteCounts = {
|
||||
users: number;
|
||||
families: number;
|
||||
activeChildren: number;
|
||||
termine: number;
|
||||
absences: number;
|
||||
announcements: number;
|
||||
};
|
||||
|
||||
export function KitaDeleteDialog({
|
||||
kitaId,
|
||||
kitaName,
|
||||
counts,
|
||||
}: {
|
||||
kitaId: string;
|
||||
kitaName: string;
|
||||
counts: DeleteCounts;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmationName, setConfirmationName] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const canSubmit = confirmationName === kitaName && !isPending;
|
||||
|
||||
function onSubmit() {
|
||||
startTransition(async () => {
|
||||
const result = await deleteKita({ kitaId, confirmationName });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error ?? "Kita konnte nicht gelöscht werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Kita wurde gelöscht.");
|
||||
setOpen(false);
|
||||
router.push(result.redirectTo ?? "/admin");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Kita löschen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kita endgültig löschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Diese Aktion löscht alle mandantengebundenen Daten dieser Kita
|
||||
unwiderruflich. Das AuditLog bleibt erhalten.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-md border border-destructive/25 bg-destructive/5 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Betroffene Daten
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span>Benutzer: {counts.users}</span>
|
||||
<span>Familien: {counts.families}</span>
|
||||
<span>Aktive Kinder: {counts.activeChildren}</span>
|
||||
<span>Termine: {counts.termine}</span>
|
||||
<span>Abwesenheiten: {counts.absences}</span>
|
||||
<span>News: {counts.announcements}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`confirm-kita-${kitaId}`}>
|
||||
Zur Bestätigung Kita-Namen eingeben
|
||||
</Label>
|
||||
<Input
|
||||
id={`confirm-kita-${kitaId}`}
|
||||
value={confirmationName}
|
||||
onChange={(event) => setConfirmationName(event.target.value)}
|
||||
placeholder={kitaName}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isPending ? "Lösche..." : "Endgültig löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock, RotateCcw, Trash2, Unlock } 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 {
|
||||
deleteUser,
|
||||
lockUser,
|
||||
resetFailedLoginAttempts,
|
||||
unlockUser,
|
||||
} from "../actions";
|
||||
|
||||
type UserActionResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function runSupportAction(
|
||||
action: () => Promise<UserActionResult>,
|
||||
successMessage: string,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
const result = await action();
|
||||
if (!result.ok) {
|
||||
toast.error(result.error ?? "Aktion konnte nicht ausgeführt werden.");
|
||||
return;
|
||||
}
|
||||
toast.success(successMessage);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
export function UserSupportActions({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isLocked: boolean;
|
||||
failedLoginAttempts: number;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{user.isLocked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
startTransition(() =>
|
||||
runSupportAction(
|
||||
() => unlockUser(user.id),
|
||||
"Benutzer wurde entsperrt.",
|
||||
router,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Unlock className="h-4 w-4" />
|
||||
Entsperren
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
startTransition(() =>
|
||||
runSupportAction(
|
||||
() => lockUser(user.id),
|
||||
"Benutzer wurde gesperrt.",
|
||||
router,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
Sperren
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending || user.failedLoginAttempts === 0}
|
||||
onClick={() =>
|
||||
startTransition(() =>
|
||||
runSupportAction(
|
||||
() => resetFailedLoginAttempts(user.id),
|
||||
"Fehlversuche wurden zurückgesetzt.",
|
||||
router,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<UserDeleteDialog user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDeleteDialog({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmationEmail, setConfirmationEmail] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const canSubmit = confirmationEmail === user.email && !isPending;
|
||||
|
||||
function onSubmit() {
|
||||
startTransition(async () => {
|
||||
const result = await deleteUser({
|
||||
userId: user.id,
|
||||
confirmationEmail,
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error ?? "Benutzer konnte nicht gelöscht werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Benutzer wurde gelöscht.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="destructive" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Benutzer endgültig löschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Löscht nur das Benutzerkonto von {user.name}. Familie und Kinder
|
||||
bleiben erhalten.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`confirm-user-${user.id}`}>
|
||||
Zur Bestätigung E-Mail-Adresse eingeben
|
||||
</Label>
|
||||
<Input
|
||||
id={`confirm-user-${user.id}`}
|
||||
value={confirmationEmail}
|
||||
onChange={(event) => setConfirmationEmail(event.target.value)}
|
||||
placeholder={user.email}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isPending ? "Lösche..." : "Endgültig löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import "server-only";
|
||||
|
||||
import { AuditTargetType, InvitationStatus, UserRole } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const roleOrder: UserRole[] = [
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
UserRole.ERZIEHER,
|
||||
UserRole.ELTERN,
|
||||
];
|
||||
|
||||
export type KitaOverviewRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
modules: {
|
||||
notdienst: boolean;
|
||||
termine: boolean;
|
||||
adressbuch: boolean;
|
||||
};
|
||||
counts: {
|
||||
users: number;
|
||||
families: number;
|
||||
activeChildren: number;
|
||||
termine: number;
|
||||
absences: number;
|
||||
announcements: number;
|
||||
openInvitations: number;
|
||||
lockedUsers: number;
|
||||
missingConsent: number;
|
||||
familiesWithoutUsers: number;
|
||||
};
|
||||
lastLoginAt: Date | null;
|
||||
roleCounts: Record<UserRole, number>;
|
||||
};
|
||||
|
||||
export type GlobalAdminHealth = {
|
||||
orphanTenantUsers: number;
|
||||
lockedTenantUsers: number;
|
||||
openInvitations: number;
|
||||
missingConsent: number;
|
||||
kitas: number;
|
||||
};
|
||||
|
||||
export async function getGlobalAdminHealth(): Promise<GlobalAdminHealth> {
|
||||
const now = new Date();
|
||||
const [
|
||||
orphanTenantUsers,
|
||||
lockedTenantUsers,
|
||||
openInvitations,
|
||||
missingConsent,
|
||||
kitas,
|
||||
] = await Promise.all([
|
||||
prisma.user.count({
|
||||
where: {
|
||||
kitaId: null,
|
||||
role: { not: UserRole.SUPERADMIN },
|
||||
},
|
||||
}),
|
||||
prisma.user.count({
|
||||
where: {
|
||||
role: { not: UserRole.SUPERADMIN },
|
||||
lockedUntil: { gt: now },
|
||||
},
|
||||
}),
|
||||
prisma.invitation.count({
|
||||
where: {
|
||||
status: InvitationStatus.PENDING,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
}),
|
||||
prisma.user.count({
|
||||
where: {
|
||||
role: { not: UserRole.SUPERADMIN },
|
||||
privacyPolicyAcceptedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.kita.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
orphanTenantUsers,
|
||||
lockedTenantUsers,
|
||||
openInvitations,
|
||||
missingConsent,
|
||||
kitas,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getKitaOverviewRows(): Promise<KitaOverviewRow[]> {
|
||||
const now = new Date();
|
||||
const kitas = await prisma.kita.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
notdienstModuleEnabled: true,
|
||||
terminModuleEnabled: true,
|
||||
adressbuchModuleEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
users: {
|
||||
select: {
|
||||
role: true,
|
||||
lastLoginAt: true,
|
||||
lockedUntil: true,
|
||||
privacyPolicyAcceptedAt: true,
|
||||
},
|
||||
},
|
||||
families: {
|
||||
select: {
|
||||
users: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
children: {
|
||||
select: { active: true },
|
||||
},
|
||||
invitations: {
|
||||
select: {
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
termine: true,
|
||||
absences: true,
|
||||
announcements: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return kitas.map((kita) => {
|
||||
const roleCounts = Object.fromEntries(
|
||||
Object.values(UserRole).map((role) => [role, 0]),
|
||||
) as Record<UserRole, number>;
|
||||
|
||||
let lastLoginAt: Date | null = null;
|
||||
let lockedUsers = 0;
|
||||
let missingConsent = 0;
|
||||
|
||||
for (const user of kita.users) {
|
||||
roleCounts[user.role] += 1;
|
||||
if (user.lastLoginAt && (!lastLoginAt || user.lastLoginAt > lastLoginAt)) {
|
||||
lastLoginAt = user.lastLoginAt;
|
||||
}
|
||||
if (user.lockedUntil && user.lockedUntil > now) {
|
||||
lockedUsers += 1;
|
||||
}
|
||||
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
|
||||
missingConsent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: kita.id,
|
||||
name: kita.name,
|
||||
slug: kita.slug,
|
||||
createdAt: kita.createdAt,
|
||||
updatedAt: kita.updatedAt,
|
||||
modules: {
|
||||
notdienst: kita.notdienstModuleEnabled,
|
||||
termine: kita.terminModuleEnabled,
|
||||
adressbuch: kita.adressbuchModuleEnabled,
|
||||
},
|
||||
counts: {
|
||||
users: kita.users.length,
|
||||
families: kita.families.length,
|
||||
activeChildren: kita.children.filter((child) => child.active).length,
|
||||
termine: kita._count.termine,
|
||||
absences: kita._count.absences,
|
||||
announcements: kita._count.announcements,
|
||||
openInvitations: kita.invitations.filter(
|
||||
(invitation) =>
|
||||
invitation.status === InvitationStatus.PENDING &&
|
||||
invitation.expiresAt > now,
|
||||
).length,
|
||||
lockedUsers,
|
||||
missingConsent,
|
||||
familiesWithoutUsers: kita.families.filter(
|
||||
(family) => family.users.length === 0,
|
||||
).length,
|
||||
},
|
||||
lastLoginAt,
|
||||
roleCounts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type KitaDiagnostics = {
|
||||
kita: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
modules: KitaOverviewRow["modules"];
|
||||
};
|
||||
counts: KitaOverviewRow["counts"] & {
|
||||
inactiveChildren: number;
|
||||
expiredPendingInvitations: number;
|
||||
};
|
||||
roleCounts: Record<UserRole, number>;
|
||||
users: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: UserRole;
|
||||
familyName: string | null;
|
||||
emailVerifiedAt: Date | null;
|
||||
privacyPolicyAcceptedAt: Date | null;
|
||||
lastLoginAt: Date | null;
|
||||
failedLoginAttempts: number;
|
||||
lockedUntil: Date | null;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
diagnostics: {
|
||||
lockedUsers: number;
|
||||
missingConsent: number;
|
||||
unverifiedUsers: number;
|
||||
familiesWithoutUsers: number;
|
||||
usersWithEmptyPassword: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getKitaDiagnostics(
|
||||
kitaId: string,
|
||||
): Promise<KitaDiagnostics | null> {
|
||||
const now = new Date();
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
notdienstModuleEnabled: true,
|
||||
terminModuleEnabled: true,
|
||||
adressbuchModuleEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
passwordHash: true,
|
||||
emailVerifiedAt: true,
|
||||
privacyPolicyAcceptedAt: true,
|
||||
lastLoginAt: true,
|
||||
failedLoginAttempts: true,
|
||||
lockedUntil: true,
|
||||
createdAt: true,
|
||||
family: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ role: "asc" },
|
||||
{ lastName: "asc" },
|
||||
{ firstName: "asc" },
|
||||
],
|
||||
},
|
||||
families: {
|
||||
select: {
|
||||
users: { select: { id: true } },
|
||||
},
|
||||
},
|
||||
children: {
|
||||
select: { active: true },
|
||||
},
|
||||
invitations: {
|
||||
select: {
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
termine: true,
|
||||
absences: true,
|
||||
announcements: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!kita) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roleCounts = Object.fromEntries(
|
||||
Object.values(UserRole).map((role) => [role, 0]),
|
||||
) as Record<UserRole, number>;
|
||||
|
||||
let lockedUsers = 0;
|
||||
let missingConsent = 0;
|
||||
let unverifiedUsers = 0;
|
||||
let usersWithEmptyPassword = 0;
|
||||
|
||||
for (const user of kita.users) {
|
||||
roleCounts[user.role] += 1;
|
||||
if (user.lockedUntil && user.lockedUntil > now) {
|
||||
lockedUsers += 1;
|
||||
}
|
||||
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
|
||||
missingConsent += 1;
|
||||
}
|
||||
if (!user.emailVerifiedAt) {
|
||||
unverifiedUsers += 1;
|
||||
}
|
||||
if (!user.passwordHash) {
|
||||
usersWithEmptyPassword += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const activeChildren = kita.children.filter((child) => child.active).length;
|
||||
const inactiveChildren = kita.children.length - activeChildren;
|
||||
const familiesWithoutUsers = kita.families.filter(
|
||||
(family) => family.users.length === 0,
|
||||
).length;
|
||||
|
||||
return {
|
||||
kita: {
|
||||
id: kita.id,
|
||||
name: kita.name,
|
||||
slug: kita.slug,
|
||||
createdAt: kita.createdAt,
|
||||
updatedAt: kita.updatedAt,
|
||||
modules: {
|
||||
notdienst: kita.notdienstModuleEnabled,
|
||||
termine: kita.terminModuleEnabled,
|
||||
adressbuch: kita.adressbuchModuleEnabled,
|
||||
},
|
||||
},
|
||||
counts: {
|
||||
users: kita.users.length,
|
||||
families: kita.families.length,
|
||||
activeChildren,
|
||||
inactiveChildren,
|
||||
termine: kita._count.termine,
|
||||
absences: kita._count.absences,
|
||||
announcements: kita._count.announcements,
|
||||
openInvitations: kita.invitations.filter(
|
||||
(invitation) =>
|
||||
invitation.status === InvitationStatus.PENDING &&
|
||||
invitation.expiresAt > now,
|
||||
).length,
|
||||
expiredPendingInvitations: kita.invitations.filter(
|
||||
(invitation) =>
|
||||
invitation.status === InvitationStatus.PENDING &&
|
||||
invitation.expiresAt <= now,
|
||||
).length,
|
||||
lockedUsers,
|
||||
missingConsent,
|
||||
familiesWithoutUsers,
|
||||
},
|
||||
roleCounts,
|
||||
users: kita.users.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
familyName: user.family?.name ?? null,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
privacyPolicyAcceptedAt: user.privacyPolicyAcceptedAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
failedLoginAttempts: user.failedLoginAttempts,
|
||||
lockedUntil: user.lockedUntil,
|
||||
createdAt: user.createdAt,
|
||||
})),
|
||||
diagnostics: {
|
||||
lockedUsers,
|
||||
missingConsent,
|
||||
unverifiedUsers,
|
||||
familiesWithoutUsers,
|
||||
usersWithEmptyPassword,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordKitaDiagnosticView({
|
||||
actorUserId,
|
||||
actorEmail,
|
||||
kitaId,
|
||||
kitaName,
|
||||
kitaSlug,
|
||||
}: {
|
||||
actorUserId: string;
|
||||
actorEmail: string;
|
||||
kitaId: string;
|
||||
kitaName: string;
|
||||
kitaSlug: string;
|
||||
}) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId,
|
||||
actorEmail,
|
||||
action: "KITA_DIAGNOSTIC_VIEW",
|
||||
targetType: AuditTargetType.KITA,
|
||||
targetId: kitaId,
|
||||
targetLabel: `${kitaName} (${kitaSlug})`,
|
||||
targetKitaId: kitaId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAuditLogRows() {
|
||||
return prisma.auditLog.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
actorEmail: true,
|
||||
action: true,
|
||||
targetType: true,
|
||||
targetId: true,
|
||||
targetLabel: true,
|
||||
targetKitaId: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
export { roleOrder };
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AuditTargetType, UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const deleteKitaSchema = z.object({
|
||||
kitaId: z.string().min(1),
|
||||
confirmationName: z.string().min(1).trim(),
|
||||
});
|
||||
|
||||
const deleteUserSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
confirmationEmail: z.string().email().toLowerCase().trim(),
|
||||
});
|
||||
|
||||
const userIdSchema = z.string().min(1);
|
||||
|
||||
type ActionResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function requireSuperadminActor() {
|
||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||
};
|
||||
}
|
||||
|
||||
async function getKitaDeleteCounts(kitaId: string) {
|
||||
const [
|
||||
users,
|
||||
families,
|
||||
children,
|
||||
termine,
|
||||
absences,
|
||||
announcements,
|
||||
invitations,
|
||||
eventParticipations,
|
||||
] = await Promise.all([
|
||||
prisma.user.count({ where: { kitaId } }),
|
||||
prisma.family.count({ where: { kitaId } }),
|
||||
prisma.child.count({ where: { kitaId } }),
|
||||
prisma.termin.count({ where: { kitaId } }),
|
||||
prisma.absence.count({ where: { kitaId } }),
|
||||
prisma.announcement.count({ where: { kitaId } }),
|
||||
prisma.invitation.count({ where: { kitaId } }),
|
||||
prisma.eventParticipation.count({ where: { kitaId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
families,
|
||||
children,
|
||||
termine,
|
||||
absences,
|
||||
announcements,
|
||||
invitations,
|
||||
eventParticipations,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteKita(
|
||||
rawPayload: unknown,
|
||||
): Promise<ActionResult & { redirectTo?: string }> {
|
||||
const actor = await requireSuperadminActor();
|
||||
const parsed = deleteKitaSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: parsed.data.kitaId },
|
||||
select: { id: true, name: true, slug: true },
|
||||
});
|
||||
|
||||
if (!kita) {
|
||||
return { ok: false, error: "Kita wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
if (parsed.data.confirmationName !== kita.name) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Die Bestätigung muss exakt dem Kita-Namen entsprechen.",
|
||||
};
|
||||
}
|
||||
|
||||
const counts = await getKitaDeleteCounts(kita.id);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorEmail: actor.email,
|
||||
action: "KITA_DELETE",
|
||||
targetType: AuditTargetType.KITA,
|
||||
targetId: kita.id,
|
||||
targetLabel: `${kita.name} (${kita.slug})`,
|
||||
targetKitaId: kita.id,
|
||||
metadata: counts,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.kita.delete({ where: { id: kita.id } });
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/audit");
|
||||
return { ok: true, redirectTo: "/admin" };
|
||||
}
|
||||
|
||||
async function findMutableTenantUser(userId: string, actorId: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
kitaId: true,
|
||||
familyId: true,
|
||||
failedLoginAttempts: true,
|
||||
lockedUntil: true,
|
||||
kita: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
_count: {
|
||||
select: {
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { error: "Benutzer wurde nicht gefunden." } as const;
|
||||
}
|
||||
if (user.id === actorId) {
|
||||
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
|
||||
}
|
||||
if (user.role === UserRole.SUPERADMIN) {
|
||||
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
|
||||
}
|
||||
if (!user.kitaId) {
|
||||
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
|
||||
}
|
||||
|
||||
return { user } as const;
|
||||
}
|
||||
|
||||
export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
||||
const actor = await requireSuperadminActor();
|
||||
const parsed = deleteUserSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const lookup = await findMutableTenantUser(parsed.data.userId, actor.id);
|
||||
if ("error" in lookup) {
|
||||
return { ok: false, error: lookup.error };
|
||||
}
|
||||
|
||||
const { user } = lookup;
|
||||
if (parsed.data.confirmationEmail !== user.email) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Die Bestätigung muss exakt der Benutzer-E-Mail entsprechen.",
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.verificationToken.deleteMany({
|
||||
where: { identifier: user.id },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorEmail: actor.email,
|
||||
action: "USER_DELETE",
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: user.id,
|
||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||
targetKitaId: user.kitaId,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
kitaName: user.kita?.name ?? null,
|
||||
kitaSlug: user.kita?.slug ?? null,
|
||||
familyId: user.familyId,
|
||||
familyName: user.family?.name ?? null,
|
||||
familyUserCountBeforeDelete: user.family?._count.users ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
revalidatePath("/admin/audit");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function updateUserSupportState(
|
||||
userId: string,
|
||||
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
|
||||
data: {
|
||||
lockedUntil?: Date | null;
|
||||
failedLoginAttempts?: number;
|
||||
},
|
||||
): Promise<ActionResult> {
|
||||
const actor = await requireSuperadminActor();
|
||||
const parsed = userIdSchema.safeParse(userId);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const lookup = await findMutableTenantUser(parsed.data, actor.id);
|
||||
if ("error" in lookup) {
|
||||
return { ok: false, error: lookup.error };
|
||||
}
|
||||
|
||||
const { user } = lookup;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: actor.id,
|
||||
actorEmail: actor.email,
|
||||
action,
|
||||
targetType: AuditTargetType.USER,
|
||||
targetId: user.id,
|
||||
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
|
||||
targetKitaId: user.kitaId,
|
||||
metadata: {
|
||||
role: user.role,
|
||||
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
|
||||
previousFailedLoginAttempts: user.failedLoginAttempts,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||
revalidatePath("/admin/audit");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function lockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_LOCK", {
|
||||
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unlockUser(userId: string): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_UNLOCK", {
|
||||
lockedUntil: null,
|
||||
failedLoginAttempts: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetFailedLoginAttempts(
|
||||
userId: string,
|
||||
): Promise<ActionResult> {
|
||||
return updateUserSupportState(userId, "USER_FAILED_LOGIN_RESET", {
|
||||
failedLoginAttempts: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ArrowLeft, ScrollText } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getAuditLogRows } from "../_lib/data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AuditLog · Kita-Planer",
|
||||
};
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
switch (action) {
|
||||
case "KITA_DIAGNOSTIC_VIEW":
|
||||
return "Diagnose geöffnet";
|
||||
case "KITA_DELETE":
|
||||
return "Kita gelöscht";
|
||||
case "USER_DELETE":
|
||||
return "Benutzer gelöscht";
|
||||
case "USER_LOCK":
|
||||
return "Benutzer gesperrt";
|
||||
case "USER_UNLOCK":
|
||||
return "Benutzer entsperrt";
|
||||
case "USER_FAILED_LOGIN_RESET":
|
||||
return "Fehlversuche zurückgesetzt";
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
function metadataPreview(metadata: unknown) {
|
||||
if (!metadata) return "—";
|
||||
const preview = JSON.stringify(metadata);
|
||||
if (!preview) return "—";
|
||||
return preview.length > 140 ? `${preview.slice(0, 140)}...` : preview;
|
||||
}
|
||||
|
||||
export default async function AuditPage() {
|
||||
await requireRole([UserRole.SUPERADMIN]);
|
||||
const rows = await getAuditLogRows();
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Button asChild variant="ghost" className="-ml-3 mb-3">
|
||||
<Link href="/admin">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zur Kita-Übersicht
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">AuditLog</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Die letzten 200 Superadmin-Diagnose- und Support-Aktionen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<ScrollText className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle as="h2" className="text-xl">
|
||||
Systemweite Aktionen
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Audit-Einträge sind unabhängig von gelöschten Kitas und Benutzern.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
Noch keine Audit-Einträge vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zeitpunkt</TableHead>
|
||||
<TableHead>Aktion</TableHead>
|
||||
<TableHead>Akteur</TableHead>
|
||||
<TableHead>Ziel</TableHead>
|
||||
<TableHead>Metadaten</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDate(row.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.action.endsWith("_DELETE")
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{actionLabel(row.action)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{row.actorEmail}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{row.targetLabel}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.targetType} · {row.targetId}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm">
|
||||
<code className="block truncate rounded bg-muted px-2 py-1 text-xs">
|
||||
{metadataPreview(row.metadata)}
|
||||
</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Baby,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Lock,
|
||||
MailCheck,
|
||||
MailWarning,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
getKitaDiagnostics,
|
||||
recordKitaDiagnosticView,
|
||||
roleOrder,
|
||||
} from "../../_lib/data";
|
||||
import { KitaDeleteDialog } from "../../_components/kita-delete-dialog";
|
||||
import { UserSupportActions } from "../../_components/user-support-actions";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ kitaId: string }>;
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kita-Diagnose · Kita-Planer",
|
||||
};
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) return "Nie";
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function isLocked(lockedUntil: Date | null) {
|
||||
return !!lockedUntil && lockedUntil > new Date();
|
||||
}
|
||||
|
||||
export default async function KitaDiagnosticsPage({ params }: PageProps) {
|
||||
const [{ kitaId }, session] = await Promise.all([
|
||||
params,
|
||||
requireRole([UserRole.SUPERADMIN]),
|
||||
]);
|
||||
|
||||
const diagnostics = await getKitaDiagnostics(kitaId);
|
||||
if (!diagnostics) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
await recordKitaDiagnosticView({
|
||||
actorUserId: session.user.id,
|
||||
actorEmail: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||
kitaId: diagnostics.kita.id,
|
||||
kitaName: diagnostics.kita.name,
|
||||
kitaSlug: diagnostics.kita.slug,
|
||||
});
|
||||
|
||||
const issueCards = [
|
||||
{
|
||||
label: "Gesperrte Konten",
|
||||
value: diagnostics.diagnostics.lockedUsers,
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
label: "Fehlender Consent",
|
||||
value: diagnostics.diagnostics.missingConsent,
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
{
|
||||
label: "Unverifizierte E-Mails",
|
||||
value: diagnostics.diagnostics.unverifiedUsers,
|
||||
icon: MailWarning,
|
||||
},
|
||||
{
|
||||
label: "Familien ohne Benutzer",
|
||||
value: diagnostics.diagnostics.familiesWithoutUsers,
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Button asChild variant="ghost" className="-ml-3 mb-3">
|
||||
<Link href="/admin">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zur Kita-Übersicht
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{diagnostics.kita.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{diagnostics.kita.slug} · angelegt am{" "}
|
||||
{formatDate(diagnostics.kita.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<KitaDeleteDialog
|
||||
kitaId={diagnostics.kita.id}
|
||||
kitaName={diagnostics.kita.name}
|
||||
counts={diagnostics.counts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Users}
|
||||
label="Benutzer"
|
||||
value={diagnostics.counts.users}
|
||||
description={`${diagnostics.counts.families} Familien`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Baby}
|
||||
label="Aktive Kinder"
|
||||
value={diagnostics.counts.activeChildren}
|
||||
description={`${diagnostics.counts.inactiveChildren} inaktiv`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Clock}
|
||||
label="Termine"
|
||||
value={diagnostics.counts.termine}
|
||||
description={`${diagnostics.counts.absences} Abwesenheiten`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={MailCheck}
|
||||
label="Offene Einladungen"
|
||||
value={diagnostics.counts.openInvitations}
|
||||
description={`${diagnostics.counts.expiredPendingInvitations} abgelaufen`}
|
||||
attention={diagnostics.counts.openInvitations > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
{issueCards.map(({ label, value, icon: Icon }) => (
|
||||
<Card
|
||||
key={label}
|
||||
className={value > 0 ? "border-amber-200 bg-amber-50/70" : ""}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 lg:grid-cols-[1fr_1.4fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle as="h2" className="text-xl">
|
||||
Rollenverteilung
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Nur tenant-gebundene Benutzer dieser Kita.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{roleOrder.map((role) => (
|
||||
<Badge key={role} variant="secondary">
|
||||
{role}: {diagnostics.roleCounts[role]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle as="h2" className="text-xl">
|
||||
Modulstatus
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
V1 zeigt Module nur read-only, keine Konfigurationsänderungen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ModuleBadge label="Notdienst" enabled={diagnostics.kita.modules.notdienst} />
|
||||
<ModuleBadge label="Termine" enabled={diagnostics.kita.modules.termine} />
|
||||
<ModuleBadge
|
||||
label="Adressbuch"
|
||||
enabled={diagnostics.kita.modules.adressbuch}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle as="h2" className="text-xl">
|
||||
Benutzer
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Support-Aktionen sind auf Löschen, Sperren, Entsperren und
|
||||
Fehlversuche zurücksetzen begrenzt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Benutzer</TableHead>
|
||||
<TableHead>Rolle / Familie</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Login</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{diagnostics.users.map((user) => {
|
||||
const userIsLocked = isLocked(user.lockedUntil);
|
||||
const userName = `${user.firstName} ${user.lastName}`.trim();
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{userName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="secondary">{user.role}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.familyName ?? "Keine Familie"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{userIsLocked ? (
|
||||
<Badge variant="destructive">
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
Gesperrt
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Aktiv
|
||||
</Badge>
|
||||
)}
|
||||
{!user.emailVerifiedAt && (
|
||||
<Badge variant="warning">E-Mail offen</Badge>
|
||||
)}
|
||||
{!user.privacyPolicyAcceptedAt && (
|
||||
<Badge variant="warning">Consent fehlt</Badge>
|
||||
)}
|
||||
{user.failedLoginAttempts > 0 && (
|
||||
<Badge variant="outline">
|
||||
{user.failedLoginAttempts} Fehlversuche
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatDate(user.lastLoginAt)}</div>
|
||||
{userIsLocked && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
bis {formatDate(user.lockedUntil)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<UserSupportActions
|
||||
user={{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: userName,
|
||||
isLocked: userIsLocked,
|
||||
failedLoginAttempts: user.failedLoginAttempts,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
attention = false,
|
||||
}: {
|
||||
icon: typeof Users;
|
||||
label: string;
|
||||
value: number;
|
||||
description: string;
|
||||
attention?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleBadge({ label, enabled }: { label: string; enabled: boolean }) {
|
||||
return (
|
||||
<Badge variant={enabled ? "success" : "outline"}>
|
||||
{enabled ? (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{label}: {enabled ? "aktiv" : "inaktiv"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LogOut, ShieldCheck } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { signOut } from "@/auth";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AdminNav } from "./_components/admin-nav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Systemadmin · Kita-Planer",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-muted/30">
|
||||
<aside className="flex w-64 shrink-0 flex-col border-r bg-background">
|
||||
<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">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold leading-none">
|
||||
Systemadmin
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Kita-Planer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<AdminNav />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
SUPERADMIN
|
||||
</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 className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Lock,
|
||||
MailWarning,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
getGlobalAdminHealth,
|
||||
getKitaOverviewRows,
|
||||
roleOrder,
|
||||
} from "./_lib/data";
|
||||
import { KitaDeleteDialog } from "./_components/kita-delete-dialog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Superadmin · Kita-Planer",
|
||||
};
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE");
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) return "Nie";
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
await requireRole([UserRole.SUPERADMIN]);
|
||||
|
||||
const [health, kitas] = await Promise.all([
|
||||
getGlobalAdminHealth(),
|
||||
getKitaOverviewRows(),
|
||||
]);
|
||||
|
||||
const totalUsers = kitas.reduce((sum, kita) => sum + kita.counts.users, 0);
|
||||
const totalChildren = kitas.reduce(
|
||||
(sum, kita) => sum + kita.counts.activeChildren,
|
||||
0,
|
||||
);
|
||||
const needsAttention =
|
||||
health.lockedTenantUsers +
|
||||
health.missingConsent +
|
||||
health.openInvitations +
|
||||
health.orphanTenantUsers;
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Kita-Verwaltung
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Systemweite Übersicht, read-only Diagnose und Support-Aktionen.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/audit">
|
||||
AuditLog ansehen
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Building2}
|
||||
label="Kitas"
|
||||
value={health.kitas}
|
||||
description="Mandanten im System"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Users}
|
||||
label="Benutzer"
|
||||
value={totalUsers}
|
||||
description={`${numberFormat.format(totalChildren)} aktive Kinder`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={AlertTriangle}
|
||||
label="Hinweise"
|
||||
value={needsAttention}
|
||||
description="Sperren, Einladungen, Consent"
|
||||
attention={needsAttention > 0}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Lock}
|
||||
label="Gesperrt"
|
||||
value={health.lockedTenantUsers}
|
||||
description="Aktive Account-Sperren"
|
||||
attention={health.lockedTenantUsers > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{health.orphanTenantUsers > 0 && (
|
||||
<div className="mb-6 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<MailWarning className="h-4 w-4" />
|
||||
{health.orphanTenantUsers} tenant-lose Nicht-Superadmin-Benutzer
|
||||
</div>
|
||||
<p className="mt-1 text-amber-800 dark:text-amber-300">
|
||||
Diese Konten sind keiner Kita zugeordnet und sollten separat
|
||||
bereinigt werden. V1 verwaltet nur tenant-gebundene Benutzer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle as="h2" className="text-xl">
|
||||
Alle Kitas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Diagnose-Links sind read-only; Löschungen benötigen eine exakte
|
||||
Namensbestätigung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{kitas.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
Noch keine Kitas angelegt.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kita</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Counts</TableHead>
|
||||
<TableHead>Rollen</TableHead>
|
||||
<TableHead>Letzter Login</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{kitas.map((kita) => {
|
||||
const warnings =
|
||||
kita.counts.lockedUsers +
|
||||
kita.counts.missingConsent +
|
||||
kita.counts.openInvitations +
|
||||
kita.counts.familiesWithoutUsers;
|
||||
return (
|
||||
<TableRow key={kita.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{kita.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{kita.slug}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{warnings === 0 ? (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Unauffällig
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{warnings} Hinweise
|
||||
</Badge>
|
||||
)}
|
||||
{kita.counts.lockedUsers > 0 && (
|
||||
<Badge variant="outline">
|
||||
{kita.counts.lockedUsers} gesperrt
|
||||
</Badge>
|
||||
)}
|
||||
{kita.counts.openInvitations > 0 && (
|
||||
<Badge variant="outline">
|
||||
{kita.counts.openInvitations} Einladungen
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="grid min-w-48 grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{kita.counts.users} Benutzer</span>
|
||||
<span>{kita.counts.families} Familien</span>
|
||||
<span>{kita.counts.activeChildren} Kinder</span>
|
||||
<span>{kita.counts.termine} Termine</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{roleOrder.map((role) =>
|
||||
kita.roleCounts[role] > 0 ? (
|
||||
<Badge key={role} variant="secondary">
|
||||
{role}: {kita.roleCounts[role]}
|
||||
</Badge>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
{formatDate(kita.lastLoginAt)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/admin/kitas/${kita.id}`}
|
||||
prefetch={false}
|
||||
>
|
||||
Diagnose
|
||||
</Link>
|
||||
</Button>
|
||||
<KitaDeleteDialog
|
||||
kitaId={kita.id}
|
||||
kitaName={kita.name}
|
||||
counts={kita.counts}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
attention = false,
|
||||
}: {
|
||||
icon: typeof Building2;
|
||||
label: string;
|
||||
value: number;
|
||||
description: string;
|
||||
attention?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{numberFormat.format(value)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { respondToAlarm } from "@/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function confirmAlertAction(token: string) {
|
||||
export async function respondAlertAction(token: string, approve: boolean) {
|
||||
if (typeof token !== "string" || token.length > 128)
|
||||
return { error: "Ungültiger Rückmeldelink." };
|
||||
if (!rateLimit(`alarm-response:${token}`, 12, 60).allowed)
|
||||
return { error: "Bitte kurz warten und erneut versuchen." };
|
||||
try {
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
return { error: "Ungültiger Bestätigungs-Link." };
|
||||
const result = await respondToAlarm(token, approve);
|
||||
// The household response is already committed; transport failure must not undo it.
|
||||
try {
|
||||
await deliverAlarmEmails(result.emailIds);
|
||||
} catch {
|
||||
/* Persistent jobs will retry. */
|
||||
}
|
||||
|
||||
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("/alert/[token]", "page");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { error: "Fehler bei der Bestätigung." };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes("prisma") &&
|
||||
!error.message.includes("Invalid `")
|
||||
)
|
||||
return { error: error.message };
|
||||
return {
|
||||
error:
|
||||
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function confirmAlertAction(token: string) {
|
||||
return respondAlertAction(token, true);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmAlertAction } from "./actions";
|
||||
import { respondAlertAction } 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!");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const [choice, setChoice] = useState<boolean | null>(null);
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
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>
|
||||
<div className="grid gap-4">
|
||||
{choice === null ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button onClick={() => setChoice(true)}>Wir übernehmen</Button>
|
||||
<Button variant="outline" onClick={() => setChoice(false)}>
|
||||
Wir können nicht
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<p className="text-sm">
|
||||
{choice
|
||||
? "Wir sagen für unseren Haushalt zu."
|
||||
: "Unser Haushalt kann diesen Einsatz nicht übernehmen."}{" "}
|
||||
Diese Antwort gilt für alle Elternkonten des Haushalts.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
setError("");
|
||||
const result = await respondAlertAction(token, choice);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
router.refresh();
|
||||
} else router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Wird gespeichert …" : "Antwort verbindlich speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setChoice(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+100
-113
@@ -1,127 +1,114 @@
|
||||
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 { BellRing, CheckCircle2 } from "lucide-react";
|
||||
import { getPublicAlarm } from "@/lib/alarms/service";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
alarmLabel,
|
||||
dateKey,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
} from "@/lib/alarms/format";
|
||||
import { AlertConfirmForm } from "./alert-confirm-form";
|
||||
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
|
||||
|
||||
export const metadata = {
|
||||
title: "Notdienstalarm · Kita-Planer",
|
||||
robots: { index: false, follow: false },
|
||||
referrer: "no-referrer" as const,
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function AlertPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
select: {
|
||||
status: true,
|
||||
parentUser: {
|
||||
select: {
|
||||
firstName: true,
|
||||
},
|
||||
},
|
||||
kita: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
assignment: {
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isPending = alert.status === NotdienstAlertStatus.PENDING;
|
||||
const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED;
|
||||
|
||||
const alarm = await getPublicAlarm(token);
|
||||
if (!alarm) notFound();
|
||||
const now = new Date(),
|
||||
expired = alarm.expiresAt <= now;
|
||||
const canRespond = alarm.status === "PENDING" && !expired;
|
||||
const overdue = alarm.deadline <= now;
|
||||
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!
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||
<section className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] shadow-sm">
|
||||
<header
|
||||
className={`border-b border-[var(--hairline)] p-6 sm:p-8 ${alarm.status === "CONFIRMED" ? "bg-[var(--accent-soft)]" : "bg-[var(--surface-2)]"}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
|
||||
Kita-Planer · {alarm.kitaName}
|
||||
</p>
|
||||
{alarm.status === "CONFIRMED" ? (
|
||||
<CheckCircle2 className="mt-6 h-7 w-7 text-[var(--accent-deep)]" />
|
||||
) : (
|
||||
<BellRing className="mt-6 h-7 w-7 text-[var(--danger)]" />
|
||||
)}
|
||||
<h1 className="mt-3 text-3xl text-[var(--ink)]">
|
||||
{alarm.status === "PENDING"
|
||||
? "Könnt ihr den Notdienst übernehmen?"
|
||||
: alarmLabel(alarm)}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Hallo {alarm.firstName}, diese Rückmeldung gilt für euren gesamten
|
||||
Haushalt.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-6 p-6 sm:p-8">
|
||||
<dl className="grid grid-cols-2 gap-5 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Datum</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{dateKey(alarm.date).split("-").reverse().join(".")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Einsatz</dt>
|
||||
<dd className="mt-1 font-semibold">{durationLabel(alarm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Haushalt</dt>
|
||||
<dd className="mt-1 font-semibold">{alarm.familyName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Antwortfrist</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{instantLabel(alarm.deadline)} Uhr
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{canRespond ? (
|
||||
<>
|
||||
{overdue && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||
Die Antwortfrist ist abgelaufen. Ihr könnt noch antworten.
|
||||
Eine verspätete Zusage muss die Koordination freigeben, da
|
||||
möglicherweise bereits Ersatz organisiert wird.
|
||||
</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>
|
||||
)}
|
||||
<AlertConfirmForm token={token} />
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-xl border border-[var(--hairline)] p-4 text-sm leading-6"
|
||||
>
|
||||
{alarm.status === "CANCELLED"
|
||||
? "Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen."
|
||||
: alarm.status === "CONFIRMED"
|
||||
? "Euer Haushalt hat zugesagt. Die Kita rechnet mit euch. Bei einer Änderung kontaktiert bitte die Koordination."
|
||||
: alarm.status === "LATE_CONFIRMATION"
|
||||
? "Eure verspätete Zusage ist gespeichert. Wartet bitte auf die Freigabe durch die Koordination."
|
||||
: alarm.status === "DECLINED"
|
||||
? "Die Absage eures Haushalts ist gespeichert. Die Koordination kümmert sich um Ersatz."
|
||||
: alarm.status === "REVIEW_REQUIRED"
|
||||
? "Die bisherigen Rückmeldungen müssen von der Koordination geprüft werden. Bitte kontaktiert die Kita."
|
||||
: "Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen."}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
Den persönlichen Link bitte innerhalb eures Haushalts behalten.
|
||||
Änderungen einer gespeicherten Antwort klärt ihr mit der Kita.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -37,6 +38,7 @@ export async function GET(
|
||||
id,
|
||||
announcement: {
|
||||
kitaId: user.kitaId,
|
||||
audience: { in: visibleAnnouncementAudiences(user) },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -56,7 +58,7 @@ export async function GET(
|
||||
headers: {
|
||||
"Content-Type": attachment.fileType,
|
||||
"Content-Disposition": contentDisposition(attachment.fileName),
|
||||
"Cache-Control": "private, max-age=300",
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function POST(request: Request) {
|
||||
const status = authorizeAlarmJob(request.headers.get("authorization"));
|
||||
if (status !== 200)
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
status === 503
|
||||
? "Fristprüfung ist nicht konfiguriert."
|
||||
: "Nicht autorisiert.",
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
try {
|
||||
const result = await runAlarmJob();
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
return Response.json(result);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Fristprüfung fehlgeschlagen; nächster Aufruf versucht es erneut.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
contactRequestSchema,
|
||||
contactRequestTypeLabels,
|
||||
type ContactRequestInput,
|
||||
type ContactSubmissionPayload,
|
||||
} from "@/lib/contact-schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -44,16 +45,27 @@ export function ContactForm() {
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: ContactRequestInput) {
|
||||
function onSubmit(
|
||||
values: ContactRequestInput,
|
||||
event?: React.BaseSyntheticEvent,
|
||||
) {
|
||||
const formEl = event?.target as HTMLFormElement | undefined;
|
||||
const website =
|
||||
(formEl?.elements.namedItem("website") as HTMLInputElement | null)
|
||||
?.value ?? "";
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await submitContactRequest(values);
|
||||
const payload: ContactSubmissionPayload = { ...values, website };
|
||||
const result = await submitContactRequest(payload);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Danke! Wir melden uns in Kürze bei euch.");
|
||||
toast.success(
|
||||
"Danke! Wir haben dir eine Bestätigung an deine E-Mail-Adresse geschickt.",
|
||||
);
|
||||
form.reset({
|
||||
name: "",
|
||||
kitaName: "",
|
||||
@@ -61,6 +73,10 @@ export function ContactForm() {
|
||||
requestType: "DEMO",
|
||||
message: "",
|
||||
});
|
||||
const honeypot = formEl?.elements.namedItem(
|
||||
"website",
|
||||
) as HTMLInputElement | null;
|
||||
if (honeypot) honeypot.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,6 +187,28 @@ export function ContactForm() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "-10000px",
|
||||
top: "auto",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<label htmlFor="contact-website">Website (bitte leer lassen)</label>
|
||||
<input
|
||||
id="contact-website"
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" disabled={isPending} className="h-12">
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
+212
-156
@@ -3,10 +3,11 @@
|
||||
import Link from "next/link";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { Baby, CalendarX2, Loader2, Trash2 } from "lucide-react";
|
||||
import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { deleteAbsence, reportAbsence } from "@/actions/absences";
|
||||
import { reportAbsence } from "@/actions/absences";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -43,7 +44,6 @@ type AbsenceListItem = {
|
||||
|
||||
export function AbsenceCard({
|
||||
childOptions,
|
||||
absences,
|
||||
today,
|
||||
}: {
|
||||
childOptions: ChildOption[];
|
||||
@@ -69,56 +69,68 @@ export function AbsenceCard({
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Abwesenheit eingetragen.");
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(absenceId: string) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteAbsence(absenceId);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Abwesenheit storniert.");
|
||||
toast.success("Abwesenheit gemeldet. Die Kita ist informiert.");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-8 border-amber-200 bg-amber-50/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<CalendarX2 className="h-5 w-5 text-amber-700" />
|
||||
Kind abmelden
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung direkt
|
||||
in der Tagesübersicht.
|
||||
</CardDescription>
|
||||
<Card variant="featured" className="min-w-0">
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<CalendarX2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Kind abmelden</CardTitle>
|
||||
<CardDescription className="mt-2 max-w-xl">
|
||||
Melde dein Kind für ganze Tage ab. Die Kita sieht die Meldung
|
||||
sofort in der Tagesübersicht — keine zusätzliche E-Mail nötig.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{hasChildren ? (
|
||||
<form action={handleSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Welches Kind?</Label>
|
||||
<Select name="childId" defaultValue={defaultChildId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Kind auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{childOptions.map((child) => (
|
||||
<SelectItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="absence-startDate">Von</Label>
|
||||
<CardContent>
|
||||
{hasChildren ? (
|
||||
<form action={handleSubmit}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FieldLabel label="Welches Kind?">
|
||||
<Select name="childId" defaultValue={defaultChildId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Kind auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{childOptions.map((child) => (
|
||||
<SelectItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldLabel>
|
||||
|
||||
<FieldLabel label="Grund">
|
||||
<Select
|
||||
name="reason"
|
||||
defaultValue={AbsenceReason.ILLNESS}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Grund auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
|
||||
<SelectItem value={AbsenceReason.VACATION}>
|
||||
Urlaub
|
||||
</SelectItem>
|
||||
<SelectItem value={AbsenceReason.OTHER}>
|
||||
Sonstiges
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldLabel>
|
||||
|
||||
<FieldLabel label="Von" htmlFor="absence-startDate">
|
||||
<Input
|
||||
id="absence-startDate"
|
||||
name="startDate"
|
||||
@@ -126,9 +138,9 @@ export function AbsenceCard({
|
||||
defaultValue={today}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="absence-endDate">Bis</Label>
|
||||
</FieldLabel>
|
||||
|
||||
<FieldLabel label="Bis" htmlFor="absence-endDate">
|
||||
<Input
|
||||
id="absence-endDate"
|
||||
name="endDate"
|
||||
@@ -136,144 +148,188 @@ export function AbsenceCard({
|
||||
defaultValue={today}
|
||||
required
|
||||
/>
|
||||
</FieldLabel>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label
|
||||
htmlFor="absence-note"
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Bemerkung{" "}
|
||||
<span className="font-medium text-[var(--ink-faint)]">
|
||||
optional
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="absence-note"
|
||||
name="note"
|
||||
rows={4}
|
||||
placeholder="z. B. voraussichtlich wieder da am Freitag."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Grund</Label>
|
||||
<Select name="reason" defaultValue={AbsenceReason.ILLNESS} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Grund auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
|
||||
<SelectItem value={AbsenceReason.VACATION}>Urlaub</SelectItem>
|
||||
<SelectItem value={AbsenceReason.OTHER}>Sonstiges</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="absence-note">Bemerkung</Label>
|
||||
<Textarea
|
||||
id="absence-note"
|
||||
name="note"
|
||||
rows={3}
|
||||
placeholder="Optional, z.B. voraussichtlich wieder da am Freitag."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="mt-7 flex items-center justify-between border-t border-[var(--hairline-soft)] pt-5">
|
||||
<Button type="reset" variant="ghost">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Check />}
|
||||
Abwesenheit melden
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center rounded-md border border-dashed bg-background/70 p-6 text-center">
|
||||
<Baby className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-4 font-medium">Noch keine Kinder hinterlegt</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="flex min-h-72 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
|
||||
<Baby className="h-10 w-10 text-[var(--ink-muted)]" />
|
||||
<p className="mt-4 font-medium text-[var(--ink)]">
|
||||
Noch keine Kinder hinterlegt
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege zuerst deine Kinder im Profil an, dann kannst du sie hier
|
||||
abmelden.
|
||||
</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Button asChild className="mt-4" variant="ghost">
|
||||
<Link href="/dashboard/profil">Zum Profil</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border bg-background/80 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="font-medium">Aktuelle & anstehende Meldungen</p>
|
||||
<Badge variant="secondary">{absences.length}</Badge>
|
||||
</div>
|
||||
{absences.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Es sind keine aktuellen oder kommenden Abwesenheiten hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{absences.map((absence) => (
|
||||
<AbsenceRow
|
||||
key={absence.id}
|
||||
absence={absence}
|
||||
disabled={isPending}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AbsenceRow({
|
||||
absence,
|
||||
disabled,
|
||||
onDelete,
|
||||
export function UpcomingAbsencesCard({
|
||||
absences,
|
||||
}: {
|
||||
absence: AbsenceListItem;
|
||||
disabled: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
absences: AbsenceListItem[];
|
||||
}) {
|
||||
const range = useMemo(() => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Anstehende Meldungen</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Aktuelle und kommende Abwesenheiten deiner Kinder.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{absences.length}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{absences.length === 0 ? (
|
||||
<div className="flex min-h-36 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--good-soft)] text-[var(--good)]">
|
||||
<Check className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
|
||||
Keine offenen Meldungen
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">
|
||||
Für die nächsten Tage ist alles ruhig.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{absences.map((absence) => (
|
||||
<AbsenceRow key={absence.id} absence={absence} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/dashboard/admin/abwesenheiten"
|
||||
className="mt-4 inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
>
|
||||
Alle Abwesenheiten
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
|
||||
const { day, month, detail } = useMemo(() => {
|
||||
const start = new Date(`${absence.startDate}T00:00:00`);
|
||||
const end = new Date(`${absence.endDate}T00:00:00`);
|
||||
const startLabel = start.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
const endLabel = end.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return absence.startDate === absence.endDate
|
||||
? endLabel
|
||||
: `${startLabel} - ${endLabel}`;
|
||||
const range =
|
||||
absence.startDate === absence.endDate
|
||||
? "ganztägig"
|
||||
: `${start.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})}-${end.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})}`;
|
||||
|
||||
return {
|
||||
day: start.toLocaleDateString("de-DE", { day: "2-digit" }),
|
||||
month: start
|
||||
.toLocaleDateString("de-DE", { month: "short" })
|
||||
.replace(".", ""),
|
||||
detail: range,
|
||||
};
|
||||
}, [absence.endDate, absence.startDate]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{absence.childName}</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{range}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(absence.id)}
|
||||
disabled={disabled}
|
||||
aria-label="Abwesenheit stornieren"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<div className="flex h-11 w-11 shrink-0 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] leading-none">
|
||||
<span className="text-base font-medium text-[var(--ink)] [font-variant-numeric:tabular-nums]">
|
||||
{day}
|
||||
</span>
|
||||
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)]">
|
||||
{month}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ReasonBadge reason={absence.reason} />
|
||||
{absence.note && (
|
||||
<span className="text-xs text-muted-foreground">{absence.note}</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--ink)]">
|
||||
{absence.childName}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--ink-soft)]">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: reasonColor(absence.reason) }}
|
||||
/>
|
||||
{reasonLabel(absence.reason)} · {detail}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="destructive">Bestätigt</Badge>
|
||||
</div>
|
||||
{absence.note ? (
|
||||
<p className="mt-1 truncate text-xs text-[var(--ink-muted)]">
|
||||
{absence.note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
|
||||
if (reason === AbsenceReason.ILLNESS) {
|
||||
return <Badge variant="destructive">Krank</Badge>;
|
||||
}
|
||||
|
||||
if (reason === AbsenceReason.VACATION) {
|
||||
return <Badge variant="warning">Urlaub</Badge>;
|
||||
}
|
||||
|
||||
return <Badge variant="outline">Sonstiges</Badge>;
|
||||
}
|
||||
|
||||
@@ -1,123 +1,10 @@
|
||||
"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";
|
||||
|
||||
import { alarmAction } from "./notdienst/alarm/actions";
|
||||
// Existing callers use the shared household workflow and the same guards.
|
||||
export async function triggerAlertAction(assignmentId: 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,
|
||||
family: {
|
||||
select: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: {
|
||||
id: true,
|
||||
email: 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." };
|
||||
}
|
||||
|
||||
const parents = assignment.child.family.users;
|
||||
|
||||
if (parents.length === 0) {
|
||||
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
|
||||
}
|
||||
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { error: mailConfigError };
|
||||
}
|
||||
|
||||
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
const alertJobs: { email: string; token: string }[] = [];
|
||||
|
||||
await prisma.$transaction(
|
||||
parents.map((parent) => {
|
||||
const token = crypto.randomUUID();
|
||||
alertJobs.push({ email: parent.email, token });
|
||||
return prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
parentUserId: parent.id,
|
||||
triggeredById: session.user.id,
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
confirmationToken: token,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
for (const job of alertJobs) {
|
||||
const emailResult = await sendAppEmail({
|
||||
to: job.email,
|
||||
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
|
||||
react: createElement(AlertEmail, {
|
||||
date: dateLabel,
|
||||
childName,
|
||||
confirmLink: `${BASE_URL}/alert/${job.token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
revalidatePath("/dashboard");
|
||||
return {
|
||||
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: "Fehler beim Auslösen des Alarms." };
|
||||
}
|
||||
return alarmAction("create", {
|
||||
assignmentId,
|
||||
wholeDay: true,
|
||||
replyMinutes: 30,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addDays, format, isWeekend, parseISO } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CalendarCheck2,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AbsenceTable, type AbsenceTableItem } from "../absence-table";
|
||||
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
||||
|
||||
interface AbsencesClientProps {
|
||||
initialAbsences: AbsenceTableItem[];
|
||||
childCount: number;
|
||||
upcomingAbsenceCount: number;
|
||||
closure: { title: string; type: string } | null;
|
||||
dateKey: string;
|
||||
selectedDateStr: string;
|
||||
isToday: boolean;
|
||||
isTooFarFuture: boolean;
|
||||
isClosedDay: boolean;
|
||||
todayStr: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function AbsencesClient({
|
||||
initialAbsences,
|
||||
childCount,
|
||||
upcomingAbsenceCount,
|
||||
closure,
|
||||
dateKey,
|
||||
selectedDateStr,
|
||||
isToday,
|
||||
isTooFarFuture,
|
||||
isClosedDay,
|
||||
todayStr,
|
||||
error,
|
||||
}: AbsencesClientProps) {
|
||||
const router = useRouter();
|
||||
const selectedDate = parseISO(selectedDateStr);
|
||||
const today = parseISO(todayStr);
|
||||
|
||||
// Filters State
|
||||
const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all");
|
||||
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
|
||||
|
||||
// Dropdown UI state
|
||||
const [isGroupOpen, setIsGroupOpen] = useState(false);
|
||||
const [isReasonOpen, setIsReasonOpen] = useState(false);
|
||||
|
||||
// Date input state
|
||||
const [inputDate, setInputDate] = useState(dateKey);
|
||||
|
||||
// Group resolver function
|
||||
const mockGroup = (childName: string) => {
|
||||
const charCode = childName.charCodeAt(0);
|
||||
return charCode % 2 === 0 ? "Krippe" : "Elementar";
|
||||
};
|
||||
|
||||
// Filter computation
|
||||
const filteredAbsences = useMemo(() => {
|
||||
return initialAbsences.filter((item) => {
|
||||
if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) {
|
||||
return false;
|
||||
}
|
||||
if (selectedReason !== "all" && item.reason !== selectedReason) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [initialAbsences, selectedGroup, selectedReason]);
|
||||
|
||||
// Recalculate reasons count based on filtered absences
|
||||
const countsByReason = useMemo(() => {
|
||||
return filteredAbsences.reduce<Partial<Record<AbsenceReason, number>>>((acc, item) => {
|
||||
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [filteredAbsences]);
|
||||
|
||||
const handleDateNavigate = (targetDate: Date) => {
|
||||
const formatted = format(targetDate, "yyyy-MM-dd");
|
||||
setInputDate(formatted);
|
||||
router.push(`/dashboard/admin/abwesenheiten?date=${formatted}`);
|
||||
};
|
||||
|
||||
const handleDateFormSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputDate) {
|
||||
router.push(`/dashboard/admin/abwesenheiten?date=${inputDate}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[1100px]:flex-col max-[1100px]:items-start">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
TAGESÜBERSICHT
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Heute fehlen{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
{filteredAbsences.length} Kinder
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
{format(selectedDate, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{childCount - filteredAbsences.length} anwesend
|
||||
</span>
|
||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||
<button
|
||||
onClick={() => handleDateNavigate(addDays(selectedDate, -1))}
|
||||
className="inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDateNavigate(today)}
|
||||
className={`inline-flex h-10 items-center gap-2 border-r border-[var(--hairline)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)] ${
|
||||
isToday ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : ""
|
||||
}`}
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDateNavigate(addDays(selectedDate, 1))}
|
||||
className="inline-flex h-10 items-center gap-2 px-4 text-[13px] font-semibold text-[var(--ink-soft)] transition hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
|
||||
>
|
||||
Vor
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleDateFormSubmit} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
value={inputDate}
|
||||
onChange={(e) => setInputDate(e.target.value)}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<Button type="submit">Anzeigen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Group Dropdown Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsGroupOpen(!isGroupOpen)}
|
||||
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
|
||||
selectedGroup !== "all"
|
||||
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
{selectedGroup === "all" ? "Alle Gruppen" : selectedGroup}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isGroupOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsGroupOpen(false)} />
|
||||
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedGroup("all");
|
||||
setIsGroupOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedGroup === "all"
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Alle Gruppen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedGroup("Krippe");
|
||||
setIsGroupOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedGroup === "Krippe"
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Krippe (Mock)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedGroup("Elementar");
|
||||
setIsGroupOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedGroup === "Elementar"
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Elementar (Mock)
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reason Dropdown Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsReasonOpen(!isReasonOpen)}
|
||||
className={`inline-flex h-9 items-center gap-2 rounded-full border px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] ${
|
||||
selectedReason !== "all"
|
||||
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "border-[var(--hairline)] bg-transparent text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
{selectedReason === "all" ? "Alle Gründe" : reasonLabel(selectedReason)}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isReasonOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsReasonOpen(false)} />
|
||||
<div className="absolute left-0 mt-1.5 w-44 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] py-1.5 shadow-lg z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedReason("all");
|
||||
setIsReasonOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedReason === "all"
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Alle Gründe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedReason(AbsenceReason.ILLNESS);
|
||||
setIsReasonOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedReason === AbsenceReason.ILLNESS
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Krankheit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedReason(AbsenceReason.VACATION);
|
||||
setIsReasonOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedReason === AbsenceReason.VACATION
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Urlaub
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedReason(AbsenceReason.OTHER);
|
||||
setIsReasonOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
||||
selectedReason === AbsenceReason.OTHER
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)] font-semibold"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
Sonstiges
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAbsences.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--ink-soft)]">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: reasonColor(AbsenceReason.ILLNESS) }}
|
||||
/>
|
||||
{reasonLabel(AbsenceReason.ILLNESS)} {countsByReason[AbsenceReason.ILLNESS] ?? 0}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: reasonColor(AbsenceReason.OTHER) }}
|
||||
/>
|
||||
{reasonLabel(AbsenceReason.OTHER)} {countsByReason[AbsenceReason.OTHER] ?? 0}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: reasonColor(AbsenceReason.VACATION) }}
|
||||
/>
|
||||
{reasonLabel(AbsenceReason.VACATION)} {countsByReason[AbsenceReason.VACATION] ?? 0}
|
||||
</span>
|
||||
<span>
|
||||
letzte Aktualisierung {format(new Date(), "HH:mm", { locale: de })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{error ? (
|
||||
<Card className="border-[var(--danger)] bg-[var(--danger-soft)]">
|
||||
<CardContent className="pt-[22px] text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredAbsences.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle>
|
||||
{filteredAbsences.length} von {childCount} Kindern abwesend ·{" "}
|
||||
{upcomingAbsenceCount} anstehend
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">{dateKey}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AbsenceTable absences={filteredAbsences} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isTooFarFuture ? (
|
||||
<EmptyState
|
||||
tone="accent"
|
||||
title="Noch nicht im Vorschauzeitraum"
|
||||
description="Abwesenheiten werden erst 30 Tage im Voraus angezeigt."
|
||||
/>
|
||||
) : isClosedDay ? (
|
||||
<EmptyState
|
||||
tone="accent"
|
||||
title={`Kita geschlossen am ${format(selectedDate, "d. MMMM", {
|
||||
locale: de,
|
||||
})}`}
|
||||
description={
|
||||
closure
|
||||
? closure.title
|
||||
: "Wochenende · für diesen Tag werden keine Anwesenheiten erwartet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
tone="good"
|
||||
title="Keine Abwesenheiten"
|
||||
description="Für diesen Tag sind aktuell keine Kinder abgemeldet."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
tone,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
tone: "good" | "accent";
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
const isGood = tone === "good";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-8 py-12 text-center">
|
||||
<div
|
||||
className={[
|
||||
"flex h-12 w-12 items-center justify-center rounded-2xl",
|
||||
isGood
|
||||
? "bg-[var(--good-soft)] text-[var(--good)]"
|
||||
: "bg-[var(--accent-soft)] text-[var(--accent-deep)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{isGood ? (
|
||||
<CalendarCheck2 className="h-6 w-6 [stroke-width:1.7]" />
|
||||
) : (
|
||||
<CalendarDays className="h-6 w-6 [stroke-width:1.7]" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-[var(--ink-soft)]">
|
||||
{description}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/admin/abwesenheiten"
|
||||
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-[var(--accent-deep)] transition hover:gap-2 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
>
|
||||
Wochenübersicht öffnen
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
|
||||
export function reasonLabel(reason: AbsenceReason) {
|
||||
if (reason === AbsenceReason.ILLNESS) return "Krank";
|
||||
if (reason === AbsenceReason.VACATION) return "Urlaub";
|
||||
return "Sonstiges";
|
||||
}
|
||||
|
||||
export function reasonColor(reason: AbsenceReason) {
|
||||
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
|
||||
if (reason === AbsenceReason.VACATION) return "var(--warn)";
|
||||
return "var(--accent)";
|
||||
}
|
||||
|
||||
export function formatDateRange(startDate: string, endDate: string) {
|
||||
const start = new Date(`${startDate}T00:00:00`);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const startLabel = start.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
const endLabel = end.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
|
||||
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export type AbsenceTableItem = {
|
||||
id: string;
|
||||
childName: string;
|
||||
familyName: string;
|
||||
reason: AbsenceReason;
|
||||
note: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
||||
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead>Zeitraum</TableHead>
|
||||
<TableHead>Grund</TableHead>
|
||||
<TableHead>Bemerkung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{absences.map((absence) => (
|
||||
<TableRow
|
||||
key={absence.id}
|
||||
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
|
||||
onClick={() => setActiveAbsence(absence)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-xs font-semibold text-[var(--accent-deep)]">
|
||||
{getInitials(absence.childName)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-[var(--ink)]">
|
||||
{absence.childName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-[var(--ink-soft)]">
|
||||
{absence.familyName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--ink-soft)]">
|
||||
{formatDateRange(absence.startDate, absence.endDate)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-2 text-[var(--ink)]">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: reasonColor(absence.reason) }}
|
||||
/>
|
||||
{reasonLabel(absence.reason)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] truncate text-[var(--ink-soft)]">
|
||||
{absence.note || "Keine Bemerkung hinterlegt."}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="success">Bestätigt</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialog
|
||||
open={!!activeAbsence}
|
||||
onOpenChange={(open) => !open && setActiveAbsence(null)}
|
||||
>
|
||||
<DialogContent className="left-auto right-0 top-0 h-full max-w-md translate-x-0 translate-y-0 rounded-none border-y-0 border-r-0 border-[var(--hairline)] bg-[var(--surface)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right">
|
||||
{activeAbsence ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
{activeAbsence.childName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{activeAbsence.familyName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<DetailLine
|
||||
icon={CalendarDays}
|
||||
label="Zeitraum"
|
||||
value={formatDateRange(
|
||||
activeAbsence.startDate,
|
||||
activeAbsence.endDate,
|
||||
)}
|
||||
/>
|
||||
<DetailLine
|
||||
icon={Circle}
|
||||
label="Grund"
|
||||
value={reasonLabel(activeAbsence.reason)}
|
||||
dotColor={reasonColor(activeAbsence.reason)}
|
||||
/>
|
||||
<DetailLine
|
||||
icon={MessageSquareText}
|
||||
label="Bemerkung"
|
||||
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
|
||||
/>
|
||||
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailLine({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
dotColor,
|
||||
}: {
|
||||
icon: typeof CalendarDays;
|
||||
label: string;
|
||||
value: string;
|
||||
dotColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-2)] p-3">
|
||||
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
{dotColor ? (
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: dotColor }}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
)}
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-[var(--ink)]">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function formatDateRange(startDate: string, endDate: string) {
|
||||
const start = new Date(`${startDate}T00:00:00`);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const startLabel = start.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
const endLabel = end.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||
}
|
||||
@@ -1,164 +1,91 @@
|
||||
import Link from "next/link";
|
||||
import { addDays, format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { ArrowLeft, ArrowRight, CalendarDays, Stethoscope } from "lucide-react";
|
||||
import { AbsenceReason } from "@prisma/client";
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns";
|
||||
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
|
||||
|
||||
import { getDailyAbsences } from "@/actions/absences";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AbsencesClient } from "./_components/absences-client";
|
||||
import { type AbsenceTableItem } from "./absence-table";
|
||||
|
||||
export const metadata = { title: "Abwesenheiten · Kita-Planer" };
|
||||
|
||||
export default async function AdminAbwesenheitenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { date?: string };
|
||||
searchParams: Promise<{ date?: string }>;
|
||||
}) {
|
||||
const selectedDate = parseDateParam(searchParams.date);
|
||||
const session = await requireRole([
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
UserRole.ERZIEHER,
|
||||
]);
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const kitaId = session.user.kitaId!;
|
||||
const today = startOfLocalDay(new Date());
|
||||
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
|
||||
const dateKey = format(selectedDate, "yyyy-MM-dd");
|
||||
const result = await getDailyAbsences({ date: dateKey });
|
||||
const absences = result.absences ?? [];
|
||||
const childCount = await prisma.child.count({
|
||||
where: { kitaId, active: true },
|
||||
});
|
||||
const upcomingAbsenceCount = await prisma.absence.count({
|
||||
where: {
|
||||
kitaId,
|
||||
startDate: { gt: selectedDate },
|
||||
},
|
||||
});
|
||||
const closure = await prisma.termin.findFirst({
|
||||
where: {
|
||||
kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
type: { in: [TerminType.SCHLIESSTAG, TerminType.TEAMTAG] },
|
||||
startDate: { lte: selectedDate },
|
||||
endDate: { gte: selectedDate },
|
||||
},
|
||||
select: { title: true, type: true },
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
const isToday = dateKey === format(today, "yyyy-MM-dd");
|
||||
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
|
||||
const isClosedDay = isWeekend(selectedDate) || !!closure;
|
||||
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: absence.childName,
|
||||
familyName: absence.familyName,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: absence.startDate,
|
||||
endDate: absence.endDate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-slate-50 px-4 py-6 sm:px-8">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-background p-5 shadow-sm lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
Tagesübersicht
|
||||
</div>
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight">
|
||||
Heute fehlen ({absences.length} Kinder)
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{format(selectedDate, "EEEE, dd. MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={hrefForDate(addDays(selectedDate, -1))}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurück
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/dashboard/admin/abwesenheiten">Heute</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={hrefForDate(addDays(selectedDate, 1))}>
|
||||
Vor
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<form className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
defaultValue={dateKey}
|
||||
className="h-9 w-40"
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Anzeigen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.error ? (
|
||||
<Card className="border-destructive/30">
|
||||
<CardContent className="p-6 text-sm text-destructive">
|
||||
{result.error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : absences.length === 0 ? (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-background p-8 text-center">
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<CalendarDays className="h-10 w-10" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||
Keine Abwesenheiten
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Für diesen Tag sind aktuell keine Kinder abgemeldet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{absences.map((absence) => (
|
||||
<Card key={absence.id} className="overflow-hidden">
|
||||
<CardHeader className="flex flex-col gap-3 p-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">
|
||||
{absence.childName}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{absence.familyName}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ReasonBadge reason={absence.reason} />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-5 pb-5 sm:grid-cols-[180px_1fr]">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Zeitraum</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatDateRange(absence.startDate, absence.endDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Notiz</p>
|
||||
<p className="text-muted-foreground">
|
||||
{absence.note || "Keine Bemerkung hinterlegt."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<AbsencesClient
|
||||
initialAbsences={tableItems}
|
||||
childCount={childCount}
|
||||
upcomingAbsenceCount={upcomingAbsenceCount}
|
||||
closure={closure}
|
||||
dateKey={dateKey}
|
||||
selectedDateStr={selectedDate.toISOString()}
|
||||
isToday={isToday}
|
||||
isTooFarFuture={isTooFarFuture}
|
||||
isClosedDay={isClosedDay}
|
||||
todayStr={today.toISOString()}
|
||||
error={result.error ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseDateParam(value: string | undefined) {
|
||||
if (!value) return new Date();
|
||||
function parseDateParam(value: string | undefined, fallback: Date) {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function hrefForDate(date: Date) {
|
||||
return `/dashboard/admin/abwesenheiten?date=${format(date, "yyyy-MM-dd")}`;
|
||||
}
|
||||
|
||||
function formatDateRange(startDate: string, endDate: string) {
|
||||
const start = new Date(`${startDate}T00:00:00`);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const startLabel = format(start, "dd.MM.", { locale: de });
|
||||
const endLabel = format(end, "dd.MM.yyyy", { locale: de });
|
||||
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||
}
|
||||
|
||||
function ReasonBadge({ reason }: { reason: AbsenceReason }) {
|
||||
if (reason === AbsenceReason.ILLNESS) {
|
||||
return <Badge variant="destructive">Krank</Badge>;
|
||||
}
|
||||
|
||||
if (reason === AbsenceReason.VACATION) {
|
||||
return <Badge variant="warning">Urlaub</Badge>;
|
||||
}
|
||||
|
||||
return <Badge variant="outline">Sonstiges</Badge>;
|
||||
function startOfLocalDay(date: Date) {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setHours(0, 0, 0, 0);
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
@@ -116,9 +116,11 @@ export function DutyPlanner({
|
||||
|
||||
if (dutyTypes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-sm font-medium">Noch keine Diensttypen</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Noch keine Diensttypen
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
|
||||
</p>
|
||||
</div>
|
||||
@@ -136,29 +138,29 @@ export function DutyPlanner({
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
<BellRing className="h-4 w-4" />
|
||||
<BellRing className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
Reminder diese Woche senden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[760px] border-collapse text-sm">
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="sticky left-0 z-10 w-56 bg-muted/95 px-4 py-3 text-left font-medium">
|
||||
<tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
|
||||
<th className="sticky left-0 z-10 w-56 bg-[var(--surface-2)] px-4 py-3 text-left text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Woche
|
||||
</th>
|
||||
{dutyTypes.map((dutyType) => (
|
||||
<th
|
||||
key={dutyType.id}
|
||||
className="min-w-56 px-4 py-3 text-left font-medium"
|
||||
className="min-w-56 px-4 py-3 text-left font-medium text-[var(--ink)]"
|
||||
>
|
||||
<div>{dutyType.name}</div>
|
||||
{dutyType.description && (
|
||||
<div className="mt-1 line-clamp-1 text-xs font-normal text-muted-foreground">
|
||||
<div className="mt-1 line-clamp-1 text-xs font-normal text-[var(--ink-muted)]">
|
||||
{dutyType.description}
|
||||
</div>
|
||||
)}
|
||||
@@ -168,8 +170,11 @@ export function DutyPlanner({
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((week) => (
|
||||
<tr key={week.key} className="border-b last:border-b-0">
|
||||
<td className="sticky left-0 z-10 bg-background px-4 py-3 font-medium">
|
||||
<tr
|
||||
key={week.key}
|
||||
className="border-b border-[var(--hairline-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-[var(--surface)] px-4 py-3 font-medium">
|
||||
{week.label}
|
||||
</td>
|
||||
{dutyTypes.map((dutyType) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { addWeeks, format, getISOWeek, startOfDay, startOfWeek } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { CalendarRange, ClipboardList, Plus, Trash2 } from "lucide-react";
|
||||
import { CalendarRange, Plus, Trash2 } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,15 +34,16 @@ const MAX_WEEK_COUNT = 104;
|
||||
export default async function DienstePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { start?: string; weeks?: string };
|
||||
searchParams: Promise<{ start?: string; weeks?: string }>;
|
||||
}) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.SUPERADMIN]);
|
||||
if (!session.user.kitaId) {
|
||||
throw new Error("Kein Mandant zugeordnet.");
|
||||
}
|
||||
|
||||
const rangeStart = getRangeStart(searchParams.start);
|
||||
const weekCount = getWeekCount(searchParams.weeks);
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const rangeStart = getRangeStart(resolvedSearchParams.start);
|
||||
const weekCount = getWeekCount(resolvedSearchParams.weeks);
|
||||
const weeks = buildWeeks(rangeStart, weekCount);
|
||||
const rangeEnd = startOfDay(new Date(weeks[weeks.length - 1].endDate));
|
||||
|
||||
@@ -83,77 +84,88 @@ export default async function DienstePage({
|
||||
|
||||
const dutyTypeDtos: DutyPlanDutyTypeDto[] = dutyTypes;
|
||||
const familyDtos: DutyPlanFamilyDto[] = families;
|
||||
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map((assignment) => ({
|
||||
id: assignment.id,
|
||||
dutyTypeId: assignment.dutyTypeId,
|
||||
familyId: assignment.familyId,
|
||||
startDate: toDateKey(assignment.startDate),
|
||||
}));
|
||||
const assignmentDtos: DutyPlanAssignmentDto[] = assignments.map(
|
||||
(assignment) => ({
|
||||
id: assignment.id,
|
||||
dutyTypeId: assignment.dutyTypeId,
|
||||
familyId: assignment.familyId,
|
||||
startDate: toDateKey(assignment.startDate),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Admin
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight">
|
||||
Dienstplan-Manager
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
ADMIN · DIENSTPLAN
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Dienste{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
planen
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Plane Dienste pro Kalenderwoche und Haushalt. Jede Änderung wird
|
||||
direkt gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{dutyTypes.length} Diensttypen
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{families.length} Familien
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="w-full lg:max-w-xl">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
Zeitraum
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Du kannst beliebig weit voraus planen, jeweils in handlichen
|
||||
Ausschnitten.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="start">Startwoche</Label>
|
||||
<Input
|
||||
id="start"
|
||||
name="start"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(rangeStart)}
|
||||
/>
|
||||
<Card className="mb-5">
|
||||
<CardContent className="pt-[22px]">
|
||||
<form className="flex flex-wrap items-end gap-3">
|
||||
<div className="mr-auto min-w-64">
|
||||
<div className="flex items-center gap-2 text-base font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
<CalendarRange className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
Zeitraum
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="weeks">Wochen</Label>
|
||||
<Input
|
||||
id="weeks"
|
||||
name="weeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_WEEK_COUNT}
|
||||
defaultValue={weekCount}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Anzeigen</Button>
|
||||
<Button asChild type="button" variant="outline">
|
||||
<Link
|
||||
href={`/dashboard/admin/dienste?start=${toDateInputValue(
|
||||
rangeStart,
|
||||
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
|
||||
>
|
||||
Weitere Wochen laden
|
||||
</Link>
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
{weeks[0].label} bis {weeks[weeks.length - 1].label}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="start">Startwoche</Label>
|
||||
<Input
|
||||
id="start"
|
||||
name="start"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(rangeStart)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-28 gap-1.5">
|
||||
<Label htmlFor="weeks">Wochen</Label>
|
||||
<Input
|
||||
id="weeks"
|
||||
name="weeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_WEEK_COUNT}
|
||||
defaultValue={weekCount}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Anzeigen</Button>
|
||||
<Button asChild type="button" variant="ghost">
|
||||
<Link
|
||||
href={`/dashboard/admin/dienste?start=${toDateInputValue(
|
||||
rangeStart,
|
||||
)}&weeks=${Math.min(weekCount + 12, MAX_WEEK_COUNT)}`}
|
||||
>
|
||||
Weitere Wochen laden
|
||||
</Link>
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[340px_1fr]">
|
||||
<Card>
|
||||
@@ -186,19 +198,19 @@ export default async function DienstePage({
|
||||
|
||||
<div className="space-y-2">
|
||||
{dutyTypes.length === 0 ? (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm italic text-[var(--ink-faint)]">
|
||||
Noch keine Diensttypen angelegt.
|
||||
</p>
|
||||
) : (
|
||||
dutyTypes.map((dutyType) => (
|
||||
<div
|
||||
key={dutyType.id}
|
||||
className="flex items-start justify-between gap-3 rounded-md border p-3"
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{dutyType.name}</p>
|
||||
<p className="font-medium text-[var(--ink)]">{dutyType.name}</p>
|
||||
{dutyType.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
<p className="mt-1 line-clamp-2 text-xs text-[var(--ink-soft)]">
|
||||
{dutyType.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -213,9 +225,10 @@ export default async function DienstePage({
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
aria-label={`${dutyType.name} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
EventParticipationStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
} from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import {
|
||||
CalendarPlus,
|
||||
Check,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
Pencil,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions";
|
||||
|
||||
type AdminTerminDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
type: TerminType;
|
||||
status: TerminStatus;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
familyParticipations: {
|
||||
familyId: string;
|
||||
status: EventParticipationStatus;
|
||||
adultCount: number;
|
||||
}[];
|
||||
rsvpDeadline: string | null;
|
||||
participantInfo: string | null;
|
||||
participations: {
|
||||
childId: string;
|
||||
status: EventParticipationStatus;
|
||||
}[];
|
||||
};
|
||||
|
||||
type ChildDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
familyName: string;
|
||||
};
|
||||
|
||||
export function AdminCalendarManager({
|
||||
termine,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termine: AdminTerminDto[];
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
|
||||
const pendingCount = termine.filter(
|
||||
(termin) => termin.status === TerminStatus.PENDING,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
ADMIN · EVENT-ANMELDUNGEN
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Termine{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
koordinieren
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
|
||||
Kind verfolgen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{termine.length} Termine
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{rsvpCount} RSVP
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-3 text-[13px] font-semibold text-[var(--warn)]">
|
||||
{pendingCount} ausstehend
|
||||
</span>
|
||||
)}
|
||||
<TerminFormDialog mode="create" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{termine.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<CalendarPlus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Noch keine Termine
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{termine.map((termin) => (
|
||||
<AdminTerminCard
|
||||
key={termin.id}
|
||||
termin={termin}
|
||||
childRows={childRows}
|
||||
familyRows={familyRows}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTerminCard({
|
||||
termin,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termin: AdminTerminDto;
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
isFamily
|
||||
? familyRows.map((family) => ({
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
familyName: family.name,
|
||||
}))
|
||||
: childRows,
|
||||
[isFamily, familyRows, childRows],
|
||||
);
|
||||
const stats = useMemo(() => getStats(termin, rows), [termin, rows]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
<Badge className={getTypeBadgeClass(termin.type)}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</Badge>
|
||||
<Badge variant={getStatusVariant(termin.status)}>
|
||||
{getStatusLabel(termin.status)}
|
||||
</Badge>
|
||||
{termin.requiresRsvp && (
|
||||
<Badge variant="warning">
|
||||
Antwort pro {isFamily ? "Familie" : "Kind"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg">{termin.title}</CardTitle>
|
||||
<CardDescription className="mt-1 flex flex-wrap items-center gap-2 text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(new Date(termin.startDate), "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
{!termin.allDay && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{format(new Date(termin.startDate), "HH:mm", {
|
||||
locale: de,
|
||||
})}{" "}
|
||||
bis{" "}
|
||||
{format(new Date(termin.endDate), "HH:mm", {
|
||||
locale: de,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<TerminFormDialog mode="edit" termin={termin} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-5 pt-5">
|
||||
{termin.description && (
|
||||
<p className="whitespace-pre-wrap text-sm text-[var(--ink-soft)]">
|
||||
{termin.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{termin.requiresRsvp ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-[var(--ink)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
|
||||
{stats.pending} Ausstehend{" "}
|
||||
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Frist:{" "}
|
||||
{termin.rsvpDeadline
|
||||
? format(
|
||||
new Date(termin.rsvpDeadline),
|
||||
"dd.MM.yyyy HH:mm",
|
||||
{
|
||||
locale: de,
|
||||
},
|
||||
)
|
||||
: "bis Terminbeginn"}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="h-5 w-5 shrink-0 text-[var(--warn)] [stroke-width:1.7]" />
|
||||
</div>
|
||||
|
||||
{termin.participantInfo && (
|
||||
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
|
||||
<span className="font-medium">Hinweis für Teilnehmer: </span>
|
||||
{termin.participantInfo}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
|
||||
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((child) => {
|
||||
const status = stats.statusByChildId.get(child.id) ?? null;
|
||||
return (
|
||||
<TableRow key={child.id}>
|
||||
<TableCell className="font-medium">
|
||||
{child.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isFamily
|
||||
? (stats.adultCounts.get(child.id) ?? "–")
|
||||
: child.familyName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RsvpStatusBadge status={status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm italic text-[var(--ink-faint)]">
|
||||
Für diesen Termin ist keine Teilnahme-Abfrage aktiv.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminFormDialog({
|
||||
mode,
|
||||
termin,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
termin?: AdminTerminDto;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [requiresRsvp, setRequiresRsvp] = useState(
|
||||
termin?.requiresRsvp ?? false,
|
||||
);
|
||||
const [rsvpMode, setRsvpMode] = useState(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
const [modeTouched, setModeTouched] = useState(false);
|
||||
const modeLocked =
|
||||
!!termin &&
|
||||
termin.participations.length + termin.familyParticipations.length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleAction(formData: FormData) {
|
||||
const startDate = new Date(String(formData.get("startDate")));
|
||||
const endDate = new Date(String(formData.get("endDate")));
|
||||
const deadlineRaw = String(formData.get("rsvpDeadline") ?? "");
|
||||
const hasRsvp = formData.get("requiresRsvp") === "on";
|
||||
|
||||
const payload = {
|
||||
title: String(formData.get("title") ?? ""),
|
||||
description: String(formData.get("description") ?? ""),
|
||||
type: formData.get("type") as TerminType,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
allDay: formData.get("allDay") === "on",
|
||||
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
|
||||
requiresRsvp: hasRsvp,
|
||||
rsvpMode,
|
||||
rsvpDeadline:
|
||||
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
|
||||
participantInfo: String(formData.get("participantInfo") ?? ""),
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "edit" && termin
|
||||
? await updateTerminAdmin(termin.id, payload)
|
||||
: await createTerminAdmin(payload);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
mode === "edit" ? "Termin aktualisiert." : "Termin angelegt.",
|
||||
);
|
||||
setOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setRequiresRsvp(termin?.requiresRsvp ?? false);
|
||||
setRsvpMode(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
setModeTouched(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={mode === "edit" ? "pill" : "default"} size="sm">
|
||||
{mode === "edit" ? (
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
) : (
|
||||
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
{mode === "edit" ? "Bearbeiten" : "Termin anlegen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<form action={handleAction}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "edit" ? "Termin bearbeiten" : "Termin anlegen"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
RSVP kann für Ausflüge und besondere Events aktiviert werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-title`}>Titel</Label>
|
||||
<Input
|
||||
id={`${mode}-title`}
|
||||
name="title"
|
||||
defaultValue={termin?.title ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-description`}>Beschreibung</Label>
|
||||
<Textarea
|
||||
id={`${mode}-description`}
|
||||
name="description"
|
||||
defaultValue={termin?.description ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-type`}>Kategorie</Label>
|
||||
<select
|
||||
id={`${mode}-type`}
|
||||
name="type"
|
||||
onChange={(event) => {
|
||||
if (!termin && !modeLocked && !modeTouched)
|
||||
setRsvpMode(
|
||||
suggestedRsvpMode(event.target.value as TerminType),
|
||||
);
|
||||
}}
|
||||
defaultValue={termin?.type ?? TerminType.KITA_FEST}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
required
|
||||
>
|
||||
{Object.values(TerminType).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-startDate`}>Start</Label>
|
||||
<Input
|
||||
id={`${mode}-startDate`}
|
||||
name="startDate"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
termin ? toDatetimeLocalValue(termin.startDate) : ""
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-endDate`}>Ende</Label>
|
||||
<Input
|
||||
id={`${mode}-endDate`}
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
termin ? toDatetimeLocalValue(termin.endDate) : ""
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allDay"
|
||||
defaultChecked={termin?.allDay ?? false}
|
||||
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
/>
|
||||
Ganztägiger Termin
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="mitbringselListEnabled"
|
||||
defaultChecked={termin?.mitbringselListEnabled ?? false}
|
||||
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
/>
|
||||
Mitbring-Liste aktiv
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="requiresRsvp"
|
||||
checked={requiresRsvp}
|
||||
onChange={(event) => setRequiresRsvp(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
/>
|
||||
Teilnahme-Abfrage (RSVP) aktivieren
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{requiresRsvp && (
|
||||
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
|
||||
<select
|
||||
id={`${mode}-rsvpMode`}
|
||||
value={rsvpMode}
|
||||
disabled={modeLocked}
|
||||
onChange={(e) => {
|
||||
setRsvpMode(e.target.value as RsvpMode);
|
||||
setModeTouched(true);
|
||||
}}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="FAMILY">
|
||||
Pro Familie · mit Anzahl Erwachsener
|
||||
</option>
|
||||
<option value="CHILD">
|
||||
Pro Kind · einzelne Zu-/Absage
|
||||
</option>
|
||||
</select>
|
||||
{modeLocked ? (
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Antwortart ist gesperrt, da bereits Rückmeldungen
|
||||
vorliegen.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>Rückmeldefrist</Label>
|
||||
<Input
|
||||
id={`${mode}-rsvpDeadline`}
|
||||
name="rsvpDeadline"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
termin?.rsvpDeadline
|
||||
? toDatetimeLocalValue(termin.rsvpDeadline)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-participantInfo`}>
|
||||
Hinweise für Teilnehmer
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`${mode}-participantInfo`}
|
||||
name="participantInfo"
|
||||
defaultValue={termin?.participantInfo ?? ""}
|
||||
placeholder="z.B. Zweite Brotdose mitbringen"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RsvpStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: EventParticipationStatus | null;
|
||||
}) {
|
||||
if (status === EventParticipationStatus.ATTENDING) {
|
||||
return (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Zugesagt
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === EventParticipationStatus.NOT_ATTENDING) {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Abgesagt
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-[var(--ink-muted)]">
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
Ausstehend
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const statusByChildId = new Map<string, EventParticipationStatus>(
|
||||
isFamily
|
||||
? termin.familyParticipations.map((row) => [row.familyId, row.status])
|
||||
: termin.participations.map((row) => [row.childId, row.status]),
|
||||
);
|
||||
const adultCounts = new Map(
|
||||
termin.familyParticipations.map((row) => [row.familyId, row.adultCount]),
|
||||
);
|
||||
let attending = 0;
|
||||
let notAttending = 0;
|
||||
let adultCount = 0;
|
||||
for (const row of rows) {
|
||||
const status = statusByChildId.get(row.id);
|
||||
if (status === EventParticipationStatus.ATTENDING) {
|
||||
attending += 1;
|
||||
adultCount += adultCounts.get(row.id) ?? 0;
|
||||
}
|
||||
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
|
||||
}
|
||||
return {
|
||||
attending,
|
||||
notAttending,
|
||||
pending: rows.length - attending - notAttending,
|
||||
statusByChildId,
|
||||
adultCounts,
|
||||
adultCount,
|
||||
};
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(value: string) {
|
||||
const date = new Date(value);
|
||||
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return localDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function getStatusVariant(
|
||||
status: TerminStatus,
|
||||
): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" {
|
||||
switch (status) {
|
||||
case TerminStatus.CONFIRMED:
|
||||
return "success";
|
||||
case TerminStatus.PENDING:
|
||||
return "warning";
|
||||
case TerminStatus.REJECTED:
|
||||
case TerminStatus.CANCELLED:
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: TerminStatus) {
|
||||
switch (status) {
|
||||
case TerminStatus.CONFIRMED:
|
||||
return "Bestätigt";
|
||||
case TerminStatus.PENDING:
|
||||
return "Ausstehend";
|
||||
case TerminStatus.REJECTED:
|
||||
return "Abgelehnt";
|
||||
case TerminStatus.CANCELLED:
|
||||
return "Abgesagt";
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type: TerminType) {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "border-transparent bg-[var(--warn-soft)] text-[var(--warn)]";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
case TerminType.TEAMTAG:
|
||||
return "border-transparent bg-[var(--danger-soft)] text-[var(--danger)]";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "border-transparent bg-[oklch(0.94_0.04_280)] text-[oklch(0.48_0.08_280)]";
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "border-transparent bg-[var(--accent-soft)] text-[var(--accent-deep)]";
|
||||
default:
|
||||
return "border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: TerminType) {
|
||||
switch (type) {
|
||||
case TerminType.KITA_FEST:
|
||||
return "Kita-Fest";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
return "Schließtag";
|
||||
case TerminType.TEAMTAG:
|
||||
return "Teamtag";
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "Mitmach-Tag";
|
||||
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";
|
||||
case TerminType.SONSTIGES:
|
||||
return "Sonstiges";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AdminCalendarManager } from "./admin-calendar-manager";
|
||||
|
||||
export const metadata = { title: "Event-Anmeldungen · Kita-Planer" };
|
||||
|
||||
export default async function AdminKalenderPage() {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
|
||||
if (!kitaId) {
|
||||
return <div className="p-8">Kein Mandant zugeordnet.</div>;
|
||||
}
|
||||
|
||||
const [termine, children, families] = await Promise.all([
|
||||
prisma.termin.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
familyParticipations: {
|
||||
select: { familyId: true, status: true, adultCount: true },
|
||||
},
|
||||
rsvpDeadline: true,
|
||||
participantInfo: true,
|
||||
participations: {
|
||||
select: {
|
||||
childId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "desc" },
|
||||
}),
|
||||
prisma.child.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
active: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ family: { name: "asc" } },
|
||||
{ lastName: "asc" },
|
||||
{ firstName: "asc" },
|
||||
],
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: { kitaId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminCalendarManager
|
||||
termine={termine.map((termin) => ({
|
||||
...termin,
|
||||
startDate: termin.startDate.toISOString(),
|
||||
endDate: termin.endDate.toISOString(),
|
||||
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
|
||||
}))}
|
||||
familyRows={families}
|
||||
childRows={children.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
familyName: child.family.name,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -64,17 +64,19 @@ export function NewsForm() {
|
||||
rows={10}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
|
||||
placeholder={
|
||||
"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."
|
||||
}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
Markdown ist aktiv. HTML wird nicht interpretiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{content.trim() && (
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
<p className="mb-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Vorschau
|
||||
</p>
|
||||
<MarkdownContent content={content} />
|
||||
@@ -83,7 +85,7 @@ export function NewsForm() {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="attachments">Anhänge</Label>
|
||||
<div className="rounded-md border border-dashed bg-background p-4">
|
||||
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<Input
|
||||
id="attachments"
|
||||
name="attachments"
|
||||
@@ -91,14 +93,14 @@ export function NewsForm() {
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
multiple
|
||||
/>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-[var(--ink-muted)]">
|
||||
<UploadCloud className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
PDF, JPG und PNG bis 10 MB pro Datei.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="sendEmail"
|
||||
@@ -110,29 +112,31 @@ export function NewsForm() {
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{sendEmail && (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<Label>Empfänger</Label>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Alle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 grid gap-2">
|
||||
<Label htmlFor="news-audience">Empfängergruppe</Label>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
|
||||
optional die E-Mail erhält.
|
||||
</p>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger id="news-audience">
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
<Send className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
Veröffentlichen
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { FileText, Megaphone } from "lucide-react";
|
||||
import { FileText, Megaphone, Send } from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { announcementAudienceLabels } from "@/lib/announcement-access";
|
||||
import { NewsForm } from "./news-form";
|
||||
|
||||
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
||||
@@ -30,6 +31,7 @@ export default async function AdminNewsPage() {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
audience: true,
|
||||
createdAt: true,
|
||||
attachments: {
|
||||
select: {
|
||||
@@ -49,26 +51,37 @@ export default async function AdminNewsPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<Badge className="mb-3 bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
|
||||
Offizielle Kita-Kommunikation
|
||||
</Badge>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-tight">
|
||||
<Megaphone className="h-6 w-6 text-emerald-700" />
|
||||
Digitales Schwarzes Brett
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur für eingeloggte Nutzer derselben
|
||||
Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
KOMMUNIKATION · SCHWARZES BRETT
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Meldungen{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
veröffentlichen
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur an die gewählte Empfängergruppe
|
||||
deiner Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{announcements.length} zuletzt
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Ankündigung</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
Neue Ankündigung
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Markdown wird sicher gerendert; PDF, JPG und PNG können als
|
||||
geschützte Anhänge hinzugefügt werden.
|
||||
@@ -88,27 +101,38 @@ export default async function AdminNewsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{announcements.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Noch keine Ankündigungen veröffentlicht.
|
||||
</p>
|
||||
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Noch keine Ankündigungen
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Veröffentlichte Meldungen erscheinen hier.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
announcements.map((announcement) => (
|
||||
<div
|
||||
key={announcement.id}
|
||||
className="rounded-md border bg-background p-3"
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className="font-medium leading-snug">
|
||||
<p className="font-medium leading-snug text-[var(--ink)]">
|
||||
{announcement.title}
|
||||
</p>
|
||||
{announcement.attachments.length > 0 && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<FileText className="h-3 w-3" />
|
||||
<FileText className="h-3 w-3 [stroke-width:1.7]" />
|
||||
{announcement.attachments.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="mb-2">
|
||||
{announcementAudienceLabels[announcement.audience]}
|
||||
</Badge>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
})}{" "}
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Baby,
|
||||
Contact,
|
||||
Mail,
|
||||
Phone,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
Home,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export type DirectoryParentDto = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
hasDirectoryOptIn: boolean;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
street?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
duties: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DirectoryFamilyDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
children: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth?: Date | null;
|
||||
}[];
|
||||
parents: DirectoryParentDto[];
|
||||
};
|
||||
|
||||
export function DirectoryClient({
|
||||
initialFamilies,
|
||||
}: {
|
||||
initialFamilies: DirectoryFamilyDto[];
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
|
||||
const [selectedFamily, setSelectedFamily] = useState<DirectoryFamilyDto | null>(null);
|
||||
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
|
||||
|
||||
const toggleFilter = (filterKey: string) => {
|
||||
setActiveFilters((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filterKey)) {
|
||||
next.delete(filterKey);
|
||||
} else {
|
||||
next.add(filterKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredFamilies = useMemo(() => {
|
||||
return initialFamilies.filter((family) => {
|
||||
// 1. Search Query filter
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (query) {
|
||||
const familyNameMatch = family.name.toLowerCase().includes(query);
|
||||
const childMatch = family.children.some(
|
||||
(c) =>
|
||||
c.firstName.toLowerCase().includes(query) ||
|
||||
c.lastName.toLowerCase().includes(query)
|
||||
);
|
||||
const parentMatch = family.parents.some(
|
||||
(p) =>
|
||||
p.firstName.toLowerCase().includes(query) ||
|
||||
p.lastName.toLowerCase().includes(query) ||
|
||||
(p.email && p.email.toLowerCase().includes(query))
|
||||
);
|
||||
if (!familyNameMatch && !childMatch && !parentMatch) return false;
|
||||
}
|
||||
|
||||
// 2. Custom filters
|
||||
if (activeFilters.has("telefon")) {
|
||||
const hasPhone = family.parents.some((p) => p.hasDirectoryOptIn && p.phone);
|
||||
if (!hasPhone) return false;
|
||||
}
|
||||
|
||||
if (activeFilters.has("aemter")) {
|
||||
const hasDuties = family.parents.some((p) => p.duties.length > 0);
|
||||
if (!hasDuties) return false;
|
||||
}
|
||||
|
||||
if (activeFilters.has("optIn")) {
|
||||
// Show households where all parents in the system have opted in
|
||||
const allOptIn = family.parents.every((p) => p.hasDirectoryOptIn);
|
||||
if (!allOptIn) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [initialFamilies, searchQuery, activeFilters]);
|
||||
|
||||
// Recalculate metrics for filtered subset
|
||||
const visibleParentCount = useMemo(() => {
|
||||
return filteredFamilies.reduce(
|
||||
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
|
||||
0
|
||||
);
|
||||
}, [filteredFamilies]);
|
||||
|
||||
const childCount = useMemo(() => {
|
||||
return filteredFamilies.reduce((count, fam) => count + fam.children.length, 0);
|
||||
}, [filteredFamilies]);
|
||||
|
||||
const totalVisibleParentCount = useMemo(() => {
|
||||
return initialFamilies.reduce(
|
||||
(count, fam) => count + fam.parents.filter((p) => p.hasDirectoryOptIn).length,
|
||||
0
|
||||
);
|
||||
}, [initialFamilies]);
|
||||
|
||||
const groupedFamilies = useMemo(() => {
|
||||
const groups = new Map<string, DirectoryFamilyDto[]>();
|
||||
filteredFamilies.forEach((family) => {
|
||||
const initial = family.name.trim().slice(0, 1).toUpperCase();
|
||||
const key = initial || "#";
|
||||
groups.set(key, [...(groups.get(key) ?? []), family]);
|
||||
});
|
||||
return Array.from(groups.entries())
|
||||
.map(([initial, families]) => ({ initial, families }))
|
||||
.sort((a, b) => a.initial.localeCompare(b.initial));
|
||||
}, [filteredFamilies]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
{/* Header */}
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
KONTAKTE · ADRESSBUCH
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Freigegebene{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
Kontakte
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Haushalte und Kontaktdaten, die explizit für das interne
|
||||
Kita-Adressbuch freigegeben wurden.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
<ShieldCheck className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{totalVisibleParentCount} sichtbar
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setPrivacyDialogOpen(true)}>
|
||||
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Sichtbarkeit
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Household List or Empty State */}
|
||||
{initialFamilies.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Contact className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Keine freigegebenen Kontakte
|
||||
</h3>
|
||||
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
|
||||
erscheint die Familie hier.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Metrics & Search Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DirectoryMetric label="Haushalte" value={filteredFamilies.length} />
|
||||
<DirectoryMetric label="Kontakte" value={visibleParentCount} />
|
||||
<DirectoryMetric label="Kinder" value={childCount} />
|
||||
</div>
|
||||
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
<Input
|
||||
placeholder="Familien, Namen oder E-Mail suchen"
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Filter Chips */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveFilters(new Set())}
|
||||
className={[
|
||||
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
|
||||
activeFilters.size === 0
|
||||
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
|
||||
].join(" ")}
|
||||
>
|
||||
Alle anzeigen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFilter("telefon")}
|
||||
className={[
|
||||
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
|
||||
activeFilters.has("telefon")
|
||||
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
|
||||
].join(" ")}
|
||||
>
|
||||
Mit Telefon
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFilter("aemter")}
|
||||
className={[
|
||||
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
|
||||
activeFilters.has("aemter")
|
||||
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
|
||||
].join(" ")}
|
||||
>
|
||||
Mit Ämtern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFilter("optIn")}
|
||||
className={[
|
||||
"inline-flex h-9 items-center rounded-full border px-3 text-[13px] font-medium transition",
|
||||
activeFilters.has("optIn")
|
||||
? "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
|
||||
].join(" ")}
|
||||
>
|
||||
Haushalt komplett freigegeben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Household list cards wrapper */}
|
||||
{filteredFamilies.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<SlidersHorizontal className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Keine passenden Kontakte gefunden
|
||||
</h3>
|
||||
<p className="mt-2 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Passe deine Suche oder die Filterchips an, um Einträge zu sehen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden p-0">
|
||||
{groupedFamilies.map((group) => (
|
||||
<section key={group.initial}>
|
||||
<div className="flex items-center justify-between border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3">
|
||||
<h2 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.14em]">
|
||||
{group.initial}
|
||||
</h2>
|
||||
<span className="text-[11px] font-semibold text-[var(--ink-muted)] tabular-nums">
|
||||
{group.families.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{group.families.map((family) => {
|
||||
const visibleParents = family.parents.filter(
|
||||
(parent) => parent.hasDirectoryOptIn
|
||||
);
|
||||
const duties = family.parents.flatMap((parent) => parent.duties);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={family.id}
|
||||
className="grid grid-cols-[minmax(220px,0.8fr)_minmax(0,1.4fr)_minmax(220px,1fr)] gap-5 px-5 py-4 transition-colors hover:bg-[var(--surface-2)] max-[1050px]:grid-cols-1"
|
||||
>
|
||||
{/* Left: Family Name & Children */}
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
|
||||
{family.name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{family.name}
|
||||
</h3>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[13px] text-[var(--ink-soft)]">
|
||||
<Baby className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{family.children.length === 0
|
||||
? "Keine Kinder hinterlegt"
|
||||
: family.children
|
||||
.map((child) => `${child.firstName} ${child.lastName}`)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: Parents */}
|
||||
<div className="grid gap-3">
|
||||
{family.parents.map((user) => (
|
||||
<div key={user.id} className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<UserRound className="h-3.5 w-3.5 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
{user.hasDirectoryOptIn ? (
|
||||
<Badge variant="success">Opt-in</Badge>
|
||||
) : (
|
||||
<span className="text-xs italic text-[var(--ink-faint)]">
|
||||
nicht freigegeben
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user.hasDirectoryOptIn && (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 pl-5 text-[13px] text-[var(--ink-soft)]">
|
||||
{user.email && (
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 hover:text-[var(--accent-deep)]"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{user.email}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{user.phone ? (
|
||||
<a
|
||||
href={`tel:${user.phone}`}
|
||||
className="inline-flex items-center gap-1.5 hover:text-[var(--accent-deep)]"
|
||||
>
|
||||
<Phone className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||
{user.phone}
|
||||
</a>
|
||||
) : (
|
||||
<span className="italic text-[var(--ink-faint)]">
|
||||
Keine Telefonnummer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Badges & Details Action */}
|
||||
<div className="flex flex-col items-end justify-between gap-3 max-[1050px]:items-start">
|
||||
<div className="flex flex-wrap justify-end gap-1.5 max-[1050px]:justify-start">
|
||||
<Badge variant="secondary">
|
||||
{visibleParents.length} von {family.parents.length} sichtbar
|
||||
</Badge>
|
||||
{duties.map((duty) => (
|
||||
<Badge
|
||||
key={duty.id}
|
||||
variant="outline"
|
||||
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
>
|
||||
{duty.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedFamily(family)}
|
||||
>
|
||||
Kontakt öffnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Household Detail Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedFamily}
|
||||
onOpenChange={(open) => !open && setSelectedFamily(null)}
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
{selectedFamily && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)] text-xl font-bold uppercase mb-2">
|
||||
{selectedFamily.name.slice(0, 1)}
|
||||
</div>
|
||||
<DialogTitle className="text-2xl">Familie {selectedFamily.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Detaillierte Kontaktdaten und Haushalts-Zugehörigkeiten.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Children Details */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
|
||||
<Baby className="h-4 w-4" />
|
||||
Kinder im Haushalt
|
||||
</h4>
|
||||
<div className="grid gap-2.5">
|
||||
{selectedFamily.children.length === 0 ? (
|
||||
<p className="text-sm italic text-[var(--ink-soft)]">Keine Kinder hinterlegt.</p>
|
||||
) : (
|
||||
selectedFamily.children.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center justify-between rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Baby className="h-4 w-4 text-[var(--accent-deep)]" />
|
||||
<span className="font-semibold text-sm text-[var(--ink)]">
|
||||
{child.firstName} {child.lastName}
|
||||
</span>
|
||||
</div>
|
||||
{child.dateOfBirth && (
|
||||
<span className="text-xs text-[var(--ink-soft)] font-medium">
|
||||
Geboren: {format(new Date(child.dateOfBirth), "dd.MM.yyyy", { locale: de })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parents/Users Details */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--ink-muted)] mb-3 flex items-center gap-1.5">
|
||||
<UserRound className="h-4 w-4" />
|
||||
Eltern & Kontakte
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{selectedFamily.parents.map((parent) => (
|
||||
<div
|
||||
key={parent.id}
|
||||
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-[var(--hairline-soft)] pb-2 mb-3">
|
||||
<span className="font-semibold text-[15px] text-[var(--ink)] flex items-center gap-1.5">
|
||||
<UserRound className="h-4 w-4 text-[var(--ink-muted)]" />
|
||||
{parent.firstName} {parent.lastName}
|
||||
</span>
|
||||
{parent.hasDirectoryOptIn ? (
|
||||
<Badge variant="success">Freigegeben</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Privat</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parent.hasDirectoryOptIn ? (
|
||||
<div className="space-y-2.5 text-sm text-[var(--ink-soft)]">
|
||||
{parent.email && (
|
||||
<a
|
||||
href={`mailto:${parent.email}`}
|
||||
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
|
||||
>
|
||||
<Mail className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
|
||||
<span className="break-all">{parent.email}</span>
|
||||
</a>
|
||||
)}
|
||||
{parent.phone ? (
|
||||
<a
|
||||
href={`tel:${parent.phone}`}
|
||||
className="flex items-center gap-2.5 hover:text-[var(--accent-deep)]"
|
||||
>
|
||||
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
|
||||
<span>{parent.phone}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 italic text-[var(--ink-faint)]">
|
||||
<Phone className="h-4 w-4 text-[var(--ink-muted)] shrink-0" />
|
||||
<span>Keine Telefonnummer hinterlegt</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address details */}
|
||||
{(parent.street || parent.postalCode || parent.city) && (
|
||||
<div className="flex items-start gap-2.5 border-t border-[var(--hairline-soft)] pt-2.5 mt-2.5">
|
||||
<MapPin className="h-4 w-4 text-[var(--ink-muted)] shrink-0 mt-0.5" />
|
||||
<div>
|
||||
{parent.street && <p>{parent.street}</p>}
|
||||
{(parent.postalCode || parent.city) && (
|
||||
<p>
|
||||
{parent.postalCode} {parent.city}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||
Dieser Kontakt hat seine Daten nicht für das Adressbuch freigegeben.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{parent.duties.length > 0 && (
|
||||
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3 flex flex-wrap gap-1.5">
|
||||
{parent.duties.map((duty) => (
|
||||
<Badge
|
||||
key={duty.id}
|
||||
variant="outline"
|
||||
className="border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)] text-[10px]"
|
||||
>
|
||||
{duty.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Global Privacy settings dialog */}
|
||||
<Dialog open={privacyDialogOpen} onOpenChange={setPrivacyDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-[var(--accent-deep)]" />
|
||||
Sichtbarkeit & Datenschutz
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2 text-sm leading-6">
|
||||
Deine Privatsphäre ist uns wichtig. In diesem Adressbuch werden Kontaktdaten
|
||||
nur für Haushalte angezeigt, die dem Opt-in für das interne Kita-Adressbuch
|
||||
explizit zugestimmt haben.
|
||||
<br />
|
||||
<br />
|
||||
Du kannst deine eigene Zustimmung sowie deine E-Mail-Adresse und Telefonnummer
|
||||
jederzeit in deinen persönlichen Profileinstellungen einsehen und anpassen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="ghost" onClick={() => setPrivacyDialogOpen(false)}>
|
||||
Schließen
|
||||
</Button>
|
||||
<a href="/dashboard/profil">
|
||||
<Button>Zu den Profileinstellungen</Button>
|
||||
</a>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryMetric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{value}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,9 @@
|
||||
import { Baby, Contact, Mail, Phone, ShieldCheck, UsersRound } from "lucide-react";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DirectoryClient, type DirectoryFamilyDto } from "./_components/directory-client";
|
||||
|
||||
export const metadata = { title: "Adressbuch · Kita-Planer" };
|
||||
|
||||
type DirectoryFamilyDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
children: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}[];
|
||||
parents: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
hasDirectoryOptIn: boolean;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
duties: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export default async function AdressbuchPage() {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
@@ -55,6 +24,7 @@ export default async function AdressbuchPage() {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
},
|
||||
@@ -91,6 +61,9 @@ export default async function AdressbuchPage() {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
street: true,
|
||||
postalCode: true,
|
||||
city: true,
|
||||
},
|
||||
});
|
||||
const visibleContactsByUserId = new Map(
|
||||
@@ -111,6 +84,9 @@ export default async function AdressbuchPage() {
|
||||
hasDirectoryOptIn,
|
||||
email: contact?.email,
|
||||
phone: contact?.phone ?? undefined,
|
||||
street: contact?.street ?? undefined,
|
||||
postalCode: contact?.postalCode ?? undefined,
|
||||
city: contact?.city ?? undefined,
|
||||
duties: user.dutyAssignments.map((assignment) => ({
|
||||
id: assignment.id,
|
||||
name: assignment.duty.name,
|
||||
@@ -119,140 +95,5 @@ export default async function AdressbuchPage() {
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Adressbuch</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Haushalte und Kontaktdaten, die explizit fuer das interne
|
||||
Kita-Adressbuch freigegeben wurden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{families.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-background p-10 text-center">
|
||||
<Contact className="mb-4 h-10 w-10 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">Keine freigegebenen Kontakte</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
Sobald mindestens ein Elternteil eines Haushalts zustimmt,
|
||||
erscheint die Familie hier.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{families.map((family) => (
|
||||
<Card key={family.id} className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<UsersRound className="h-4 w-4 text-primary" />
|
||||
{family.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{family.parents.filter((user) => user.hasDirectoryOptIn).length}{" "}
|
||||
von {family.parents.length} Elternteilen sichtbar
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="success">Freigegeben</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-5 pt-5">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Baby className="h-3.5 w-3.5" />
|
||||
Kinder
|
||||
</div>
|
||||
{family.children.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keine Kinder hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{family.children.map((child) => (
|
||||
<Badge key={child.id} variant="secondary">
|
||||
{child.firstName} {child.lastName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Kontakte
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{family.parents.map((user) => {
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
{!user.hasDirectoryOptIn && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Kontakt nicht freigegeben.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user.hasDirectoryOptIn ? (
|
||||
<Badge variant="outline">Opt-in</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{user.hasDirectoryOptIn && (
|
||||
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
|
||||
{user.email && (
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
className="flex min-w-0 items-center gap-2 hover:text-primary"
|
||||
>
|
||||
<Mail className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{user.email}</span>
|
||||
</a>
|
||||
)}
|
||||
{user.phone && (
|
||||
<a
|
||||
href={`tel:${user.phone}`}
|
||||
className="flex items-center gap-2 hover:text-primary"
|
||||
>
|
||||
<Phone className="h-4 w-4 shrink-0" />
|
||||
{user.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.duties.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5 border-t pt-3">
|
||||
{user.duties.map((duty) => (
|
||||
<Badge
|
||||
key={duty.id}
|
||||
variant="outline"
|
||||
className="bg-primary/5 text-primary"
|
||||
>
|
||||
{duty.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <DirectoryClient initialFamilies={families} />;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Loader2, BellRing } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { triggerAlertAction } from "./actions";
|
||||
|
||||
export function AlertButton({
|
||||
@@ -12,11 +13,10 @@ export function AlertButton({
|
||||
}: {
|
||||
assignmentId: string;
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleAlert = () => {
|
||||
if (!confirm("Bist du sicher? Dies löst den Notdienst-Alarm aus.")) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await triggerAlertAction(assignmentId);
|
||||
if ("error" in result && result.error) {
|
||||
@@ -28,13 +28,24 @@ export function AlertButton({
|
||||
};
|
||||
|
||||
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>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title="Notdienst-Alarm auslösen?"
|
||||
description="Der Alarm informiert die hinterlegten Kontakte per E-Mail über den Notdienst."
|
||||
confirmLabel="Alarm auslösen"
|
||||
pendingLabel="Alarm wird ausgelöst..."
|
||||
isPending={isPending}
|
||||
onConfirm={handleAlert}
|
||||
>
|
||||
<Button variant="destructive" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<BellRing className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Alarm auslösen
|
||||
</Button>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,6 +21,7 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -27,18 +31,115 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
import { createEducator, updateEducator, deleteEducator } from "../actions";
|
||||
import {
|
||||
createEducator,
|
||||
updateEducator,
|
||||
deleteEducator,
|
||||
resendEducatorInvitation,
|
||||
} from "../actions";
|
||||
|
||||
type EducatorDto = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
hasProfile: boolean;
|
||||
email: string | null;
|
||||
verified: boolean;
|
||||
inviteFailed: boolean;
|
||||
};
|
||||
|
||||
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
function AccessFields({
|
||||
prefix,
|
||||
today,
|
||||
educator,
|
||||
}: {
|
||||
prefix: string;
|
||||
today: string;
|
||||
educator?: EducatorDto;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-email`}>E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id={`${prefix}-email`}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
defaultValue={educator?.email ?? ""}
|
||||
readOnly={!!educator?.email}
|
||||
required
|
||||
maxLength={254}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
{educator?.email
|
||||
? "Diese Adresse gehört zum verknüpften Account."
|
||||
: "An diese Adresse senden wir den persönlichen Aktivierungslink."}
|
||||
</p>
|
||||
</div>
|
||||
{!educator?.hasProfile && (
|
||||
<div className="grid gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-employmentStart`}>
|
||||
Beschäftigungsbeginn
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-employmentStart`}
|
||||
name="employmentStart"
|
||||
type="date"
|
||||
required
|
||||
min="2000-01-01"
|
||||
max="2100-12-31"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-trackingStart`}>
|
||||
Start der Zeiterfassung
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-trackingStart`}
|
||||
name="trackingStart"
|
||||
type="date"
|
||||
defaultValue={today}
|
||||
required
|
||||
min="2000-01-01"
|
||||
max="2100-12-31"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)] sm:col-span-2">
|
||||
Ab diesem Datum werden Arbeitszeiten im Kita-Planer erfasst.
|
||||
Arbeitstage, Urlaub und vorhandene Überstunden richtest du
|
||||
anschließend im Personalprofil ein.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErzieherList({
|
||||
educators,
|
||||
today,
|
||||
}: {
|
||||
educators: EducatorDto[];
|
||||
today: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const filtered = educators.filter((ed) =>
|
||||
`${ed.firstName} ${ed.lastName} ${ed.email ?? ""}`
|
||||
.toLocaleLowerCase("de")
|
||||
.includes(search.toLocaleLowerCase("de")),
|
||||
);
|
||||
const [openCreate, setOpenCreate] = useState(false);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleCreate = (formData: FormData) => {
|
||||
@@ -46,15 +147,24 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
firstName: formData.get("firstName") as string,
|
||||
lastName: formData.get("lastName") as string,
|
||||
active: formData.get("active") === "on",
|
||||
email: formData.get("email"),
|
||||
employmentStart: formData.get("employmentStart"),
|
||||
trackingStart: formData.get("trackingStart"),
|
||||
};
|
||||
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await createEducator(data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
setFormError(res.error);
|
||||
} else {
|
||||
toast.success("ErzieherIn angelegt.");
|
||||
toast.success(res.message ?? "Fachkraft und Personalprofil angelegt.");
|
||||
if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
setOpenCreate(false);
|
||||
if (res.educatorId)
|
||||
router.push(
|
||||
`/dashboard/personal?tab=verwaltung&educator=${res.educatorId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -65,50 +175,90 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
firstName: formData.get("firstName") as string,
|
||||
lastName: formData.get("lastName") as string,
|
||||
active: formData.get("active") === "on",
|
||||
email: formData.get("email"),
|
||||
employmentStart: formData.get("employmentStart"),
|
||||
trackingStart: formData.get("trackingStart"),
|
||||
};
|
||||
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await updateEducator(editingEducator.id, data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
setFormError(res.error);
|
||||
} else {
|
||||
toast.success("Daten aktualisiert.");
|
||||
toast.success(res.message ?? "Daten aktualisiert.");
|
||||
if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
setEditingEducator(null);
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (!confirm("Wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) return;
|
||||
const handleResend = (educator: EducatorDto) => {
|
||||
startTransition(async () => {
|
||||
const res = await deleteEducator(id);
|
||||
const res = await resendEducatorInvitation(educator.id);
|
||||
if (res.error) toast.error(res.error);
|
||||
else if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
else toast.success(res.message ?? "Einladung erneuert.");
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (educator: EducatorDto) => {
|
||||
startTransition(async () => {
|
||||
const res = await deleteEducator(educator.id);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("ErzieherIn gelöscht.");
|
||||
setEducatorToDelete(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
<Input
|
||||
placeholder="Name oder E-Mail suchen"
|
||||
aria-label="Team durchsuchen"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Dialog
|
||||
open={openCreate}
|
||||
onOpenChange={(open) => {
|
||||
if (!isPending) {
|
||||
setOpenCreate(open);
|
||||
setFormError("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="h-4 w-4 [stroke-width:1.7]" />
|
||||
ErzieherIn anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleCreate}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleCreate(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue(n) ErzieherIn anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erfasse die Stammdaten des Kita-Personals.
|
||||
Lege Stammdaten, Personalprofil und App-Zugang gemeinsam an.
|
||||
Die Fachkraft erhält ihren Aktivierungslink per E-Mail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<fieldset disabled={isPending} className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="firstName">Vorname</Label>
|
||||
<Input id="firstName" name="firstName" required />
|
||||
@@ -117,92 +267,255 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
<Label htmlFor="lastName">Nachname</Label>
|
||||
<Input id="lastName" name="lastName" required />
|
||||
</div>
|
||||
<AccessFields prefix="create" today={today} />
|
||||
<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>
|
||||
</fieldset>
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Speichern</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setOpenCreate(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? "Wird angelegt …"
|
||||
: "Fachkraft anlegen und einladen"}
|
||||
</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 ? (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Team</CardTitle>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
{educators.length} Einträge im Personalstamm.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{educators.filter((ed) => ed.active).length} aktiv
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground h-24">
|
||||
Keine Einträge gefunden.
|
||||
</TableCell>
|
||||
<TableHead className="pl-6">Vorname</TableHead>
|
||||
<TableHead>Nachname</TableHead>
|
||||
<TableHead>E-Mail / App-Zugang</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center">
|
||||
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Plus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Keine Einträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege die erste Person im Team an.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((ed) => (
|
||||
<TableRow key={ed.id}>
|
||||
<TableCell className="pl-6 font-medium">
|
||||
{ed.firstName}
|
||||
</TableCell>
|
||||
<TableCell>{ed.lastName}</TableCell>
|
||||
<TableCell>
|
||||
<p className="mb-1 text-sm">
|
||||
{ed.email ?? "Noch keine E-Mail hinterlegt"}
|
||||
</p>
|
||||
<Badge variant={ed.verified ? "success" : "secondary"}>
|
||||
{ed.verified
|
||||
? "Account aktiviert"
|
||||
: ed.inviteFailed
|
||||
? "Mailversand fehlgeschlagen"
|
||||
: ed.email
|
||||
? "Einladung ausstehend"
|
||||
: "Zugang fehlt"}
|
||||
</Badge>
|
||||
{ed.email && !ed.verified && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => handleResend(ed)}
|
||||
aria-label={`Einladung für ${ed.firstName} ${ed.lastName} erneut senden`}
|
||||
className="mt-1 block"
|
||||
>
|
||||
Einladung erneut senden
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ed.active ? "success" : "secondary"}>
|
||||
{ed.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mr-2"
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/personal?tab=verwaltung&educator=${ed.id}`}
|
||||
>
|
||||
Personalprofil
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setFormError("");
|
||||
setEditingEducator(ed);
|
||||
}}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} bearbeiten`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setEducatorToDelete(ed)}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
|
||||
<DialogContent>
|
||||
<form action={handleUpdate}>
|
||||
<Dialog
|
||||
open={!!editingEducator}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isPending) setEditingEducator(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleUpdate(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editingEducator && (
|
||||
<div className="grid gap-4 py-4">
|
||||
<fieldset disabled={isPending} 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 />
|
||||
<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 />
|
||||
<Input
|
||||
id="edit-lastName"
|
||||
name="lastName"
|
||||
defaultValue={editingEducator.lastName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<AccessFields
|
||||
prefix="edit"
|
||||
today={today}
|
||||
educator={editingEducator}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
|
||||
<Switch
|
||||
id="edit-active"
|
||||
name="active"
|
||||
defaultChecked={editingEducator.active}
|
||||
/>
|
||||
<Label htmlFor="edit-active">Aktiv</Label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setEditingEducator(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? "Wird gespeichert …"
|
||||
: editingEducator?.email
|
||||
? "Aktualisieren"
|
||||
: "Speichern und einladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!educatorToDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEducatorToDelete(null);
|
||||
}}
|
||||
title="ErzieherIn löschen?"
|
||||
description={
|
||||
educatorToDelete
|
||||
? `${educatorToDelete.firstName} ${educatorToDelete.lastName} wird aus dem Personalstamm entfernt.`
|
||||
: "Dieser Eintrag wird aus dem Personalstamm entfernt."
|
||||
}
|
||||
confirmLabel="Löschen"
|
||||
pendingLabel="Wird gelöscht..."
|
||||
isPending={isPending}
|
||||
onConfirm={() => {
|
||||
if (educatorToDelete) handleDelete(educatorToDelete);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,74 +6,23 @@ import { z } from "zod";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { staffAction } from "../personal/actions";
|
||||
|
||||
const educatorSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// The staff service creates the person, profile, account and mail job in one transaction.
|
||||
export async function createEducator(rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.educator.create({
|
||||
data: {
|
||||
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." };
|
||||
}
|
||||
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
|
||||
return staffAction("educatorCreate", parsed.data);
|
||||
}
|
||||
|
||||
export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
|
||||
return staffAction("educatorUpdate", { ...parsed.data, educatorId: id });
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.educator.update({
|
||||
where: {
|
||||
id,
|
||||
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 resendEducatorInvitation(educatorId: string) {
|
||||
return staffAction("educatorInvite", { educatorId });
|
||||
}
|
||||
|
||||
export async function deleteEducator(id: string) {
|
||||
@@ -84,6 +33,14 @@ export async function deleteEducator(id: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await prisma.staffProfile.findFirst({
|
||||
where: { educatorId: id, kitaId },
|
||||
});
|
||||
if (profile)
|
||||
return {
|
||||
error:
|
||||
"Diese Fachkraft hat ein Personalprofil. Bitte auf inaktiv setzen, damit Zeiten und Konten erhalten bleiben.",
|
||||
};
|
||||
await prisma.educator.delete({
|
||||
where: {
|
||||
id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { todayKey } from "@/lib/staff/calculations";
|
||||
import { ErzieherList } from "./_components/erzieher-list";
|
||||
|
||||
export const metadata = { title: "ErzieherInnen · Kita-Planer" };
|
||||
@@ -17,26 +18,62 @@ export default async function ErzieherPage() {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
active: true,
|
||||
staffProfile: {
|
||||
select: {
|
||||
id: true,
|
||||
user: { select: { email: true, emailVerifiedAt: true } },
|
||||
emails: {
|
||||
where: { inviteToken: { not: null }, skippedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { failed: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
});
|
||||
const activeCount = educators.filter((educator) => educator.active).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<GraduationCap className="h-6 w-6 text-primary" />
|
||||
ErzieherInnen-Verwaltung
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
VERWALTUNG · TEAM
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
ErzieherInnen{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
verwalten
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Stammdaten des Kita-Personals für interne Planung und spätere
|
||||
Notdienst-Alarmierung.
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Stammdaten des Teams und Zugang zu Beschäftigungsprofilen,
|
||||
Urlaubsansprüchen und Arbeitszeitmodellen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<GraduationCap className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||
{educators.length} im Team
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
{activeCount} aktiv
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ErzieherList educators={educators} />
|
||||
<ErzieherList
|
||||
today={todayKey()}
|
||||
educators={educators.map(({ staffProfile, ...educator }) => ({
|
||||
...educator,
|
||||
hasProfile: !!staffProfile,
|
||||
email: staffProfile?.user?.email ?? null,
|
||||
verified: !!staffProfile?.user?.emailVerifiedAt,
|
||||
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table as UITable,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { AddFamilyDialog } from "../add-family-dialog";
|
||||
import { DutyManager } from "../duty-manager";
|
||||
import { EditFamilyDialog } from "../edit-family-dialog";
|
||||
|
||||
interface FamilyUser {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
emailVerifiedAt: Date | null;
|
||||
dutyAssignments: {
|
||||
id: string;
|
||||
dutyId: string;
|
||||
duty: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
interface FamilyChild {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface FamilyData {
|
||||
id: string;
|
||||
name: string;
|
||||
users: FamilyUser[];
|
||||
children: FamilyChild[];
|
||||
}
|
||||
|
||||
interface DutyData {
|
||||
id: string;
|
||||
name: string;
|
||||
assignments: {
|
||||
id: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface FamiliesListClientProps {
|
||||
initialFamilies: FamilyData[];
|
||||
allDuties: DutyData[];
|
||||
canManageDuties: boolean;
|
||||
}
|
||||
|
||||
export function FamiliesListClient({
|
||||
initialFamilies,
|
||||
allDuties,
|
||||
canManageDuties,
|
||||
}: FamiliesListClientProps) {
|
||||
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredFamilies = initialFamilies.filter((family) => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (!q) return true;
|
||||
|
||||
// Check family name
|
||||
if (family.name.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Check parents
|
||||
const matchesParent = family.users.some(
|
||||
(user) =>
|
||||
user.firstName.toLowerCase().includes(q) ||
|
||||
user.lastName.toLowerCase().includes(q) ||
|
||||
user.email.toLowerCase().includes(q)
|
||||
);
|
||||
if (matchesParent) return true;
|
||||
|
||||
// Check children
|
||||
const matchesChild = family.children.some(
|
||||
(child) =>
|
||||
child.firstName.toLowerCase().includes(q) ||
|
||||
child.lastName.toLowerCase().includes(q)
|
||||
);
|
||||
return matchesChild;
|
||||
});
|
||||
|
||||
const childCount = filteredFamilies.reduce(
|
||||
(acc, family) => acc + family.children.length,
|
||||
0
|
||||
);
|
||||
|
||||
const pendingInviteCount = filteredFamilies.reduce(
|
||||
(acc, family) =>
|
||||
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:flex-col max-[900px]:items-start">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
VERWALTUNG · FAMILIEN
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Familien{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
organisieren
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{viewMode === "table" ? (
|
||||
<>
|
||||
<LayoutGrid className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Kartenansicht
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Table className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Tabellenansicht
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<AddFamilyDialog />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
|
||||
Familien
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{childCount}</span>
|
||||
Kinder
|
||||
</span>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
|
||||
Offene Einladungen
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
<Input
|
||||
placeholder="Familien, Eltern oder Kinder suchen"
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredFamilies.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<div className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Users className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="mb-1 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Keine Familien gefunden
|
||||
</h3>
|
||||
<p className="mb-5 max-w-sm text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Passe deine Suche an oder erstelle eine neue Familie.
|
||||
</p>
|
||||
{initialFamilies.length === 0 && <AddFamilyDialog />}
|
||||
</div>
|
||||
</Card>
|
||||
) : viewMode === "table" ? (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Haushalte</CardTitle>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
||||
{childCount} Kinder · {pendingInviteCount} offene Einladungen
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{filteredFamilies.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0 overflow-x-auto">
|
||||
<UITable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">Familie</TableHead>
|
||||
<TableHead>Elternteile</TableHead>
|
||||
<TableHead>Kinder</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFamilies.map((family) => {
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={family.id}>
|
||||
<TableCell className="pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[13px] font-semibold uppercase text-[var(--accent-deep)]">
|
||||
{family.name.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--ink)]">
|
||||
{family.name}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--ink-muted)]">
|
||||
{family.users.length} Elternteil
|
||||
{family.users.length === 1 ? "" : "e"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
{family.users.map((parent) => (
|
||||
<div key={parent.id} className="min-w-0">
|
||||
<div className="font-medium text-[var(--ink)]">
|
||||
{parent.firstName} {parent.lastName}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--ink-muted)]">
|
||||
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{family.children.length === 0 ? (
|
||||
<span className="text-sm italic text-[var(--ink-faint)]">
|
||||
Noch keine Kinder
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{family.children.map((child) => (
|
||||
<span
|
||||
key={child.id}
|
||||
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-medium text-[var(--ink-soft)]"
|
||||
>
|
||||
{child.firstName} {child.lastName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
<EditFamilyDialog
|
||||
family={{
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
parents: family.users.map((parent) => ({
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
/>
|
||||
{canManageDuties &&
|
||||
family.users.map((parent) => (
|
||||
<DutyManager
|
||||
key={parent.id}
|
||||
user={{
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
}}
|
||||
allDuties={allDuties}
|
||||
userAssignments={parent.dutyAssignments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</UITable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredFamilies.map((family) => {
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt
|
||||
);
|
||||
return (
|
||||
<Card key={family.id} className="flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-sm font-semibold uppercase text-[var(--accent-deep)]">
|
||||
{family.name.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-4 pt-0">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
|
||||
Eltern
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{family.users.map((parent) => (
|
||||
<div key={parent.id} className="text-sm">
|
||||
<div className="font-semibold text-[var(--ink)]">
|
||||
{parent.firstName} {parent.lastName}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--ink-muted)] mt-0.5">
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{parent.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-[var(--ink-muted)] mb-1">
|
||||
Kinder
|
||||
</p>
|
||||
{family.children.length === 0 ? (
|
||||
<span className="text-xs italic text-[var(--ink-faint)]">
|
||||
Noch keine Kinder
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{family.children.map((child) => (
|
||||
<span
|
||||
key={child.id}
|
||||
className="inline-flex items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-xs text-[var(--ink-soft)] font-medium"
|
||||
>
|
||||
{child.firstName} {child.lastName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-[var(--hairline)] pt-3 mt-auto flex flex-wrap gap-1 justify-end">
|
||||
<EditFamilyDialog
|
||||
family={{
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
parents: family.users.map((parent) => ({
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
/>
|
||||
{canManageDuties &&
|
||||
family.users.map((parent) => (
|
||||
<DutyManager
|
||||
key={parent.id}
|
||||
user={{
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
}}
|
||||
allDuties={allDuties}
|
||||
userAssignments={parent.dutyAssignments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function AddFamilyDialog() {
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Plus className="h-4 w-4" />
|
||||
Familie hinzufügen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -82,7 +82,9 @@ export function AddFamilyDialog() {
|
||||
|
||||
{/* ── Elternteile ────────────────────────────────────────── */}
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="text-sm font-semibold">Elternteil 1</legend>
|
||||
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Elternteil 1
|
||||
</legend>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
@@ -112,8 +114,13 @@ export function AddFamilyDialog() {
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="text-sm font-semibold">
|
||||
Elternteil 2 <span className="font-normal text-muted-foreground">(optional)</span>
|
||||
<legend>
|
||||
<span className="text-[11px] uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Elternteil 2
|
||||
</span>{" "}
|
||||
<span className="font-normal italic text-[var(--ink-faint)]">
|
||||
optional
|
||||
</span>
|
||||
</legend>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -149,7 +156,7 @@ export function AddFamilyDialog() {
|
||||
{/* ── Kinder ─────────────────────────────────────────────── */}
|
||||
<fieldset className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<legend className="text-sm font-semibold">
|
||||
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kinder ({childCount})
|
||||
</legend>
|
||||
{childCount < 10 && (
|
||||
@@ -168,7 +175,7 @@ export function AddFamilyDialog() {
|
||||
{Array.from({ length: childCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative rounded-md border p-4"
|
||||
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
|
||||
>
|
||||
{childCount > 1 && (
|
||||
<button
|
||||
@@ -178,13 +185,13 @@ export function AddFamilyDialog() {
|
||||
// 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"
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-[var(--ink-muted)] transition-colors hover:bg-[var(--danger-soft)] hover:text-[var(--danger)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]"
|
||||
aria-label={`Kind ${i + 1} entfernen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<p className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kind {i + 1}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -203,7 +210,7 @@ export function AddFamilyDialog() {
|
||||
))}
|
||||
|
||||
{state.errors?.children?.[0] && (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className="text-xs text-[var(--danger)]">
|
||||
{state.errors.children[0]}
|
||||
</p>
|
||||
)}
|
||||
@@ -211,7 +218,7 @@ export function AddFamilyDialog() {
|
||||
|
||||
{/* Globaler Fehler */}
|
||||
{state.errors?._form?.[0] && (
|
||||
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<p className="rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
|
||||
{state.errors._form[0]}
|
||||
</p>
|
||||
)}
|
||||
@@ -219,7 +226,7 @@ export function AddFamilyDialog() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
@@ -268,7 +275,7 @@ function FormField({
|
||||
required={required}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{error && <p className="text-xs text-[var(--danger)]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,10 +107,10 @@ export function DutyManager({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
||||
<Shield className="h-4 w-4" />
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Ämter</span>
|
||||
{userAssignments.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-4">
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
|
||||
{userAssignments.length}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -127,16 +127,20 @@ export function DutyManager({
|
||||
<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>
|
||||
<h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Aktuelle Ämter
|
||||
</h4>
|
||||
{userAssignments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">Keine Ämter zugewiesen.</p>
|
||||
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
|
||||
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">
|
||||
<Badge key={assignment.id} variant="default" className="gap-1 pr-1">
|
||||
{assignment.duty.name}
|
||||
<button
|
||||
className="ml-1 rounded-full hover:bg-primary-foreground/20 p-0.5"
|
||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-[oklch(0.98_0.005_45_/_0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[oklch(0.98_0.005_45)]"
|
||||
onClick={() => handleRemove(assignment.id)}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -149,17 +153,21 @@ export function DutyManager({
|
||||
</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>
|
||||
<div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
|
||||
<h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Vorhandenes Amt zuweisen
|
||||
</h4>
|
||||
{unassignedDuties.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">Keine weiteren Ämter verfügbar.</p>
|
||||
<p className="text-sm italic text-[var(--ink-faint)]">
|
||||
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"
|
||||
className="group relative cursor-pointer gap-1 pr-1 hover:border-[var(--accent-mid)] hover:bg-[var(--accent-soft)] hover:text-[var(--accent-deep)]"
|
||||
onClick={() => handleAssign(duty.id)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -168,7 +176,7 @@ export function DutyManager({
|
||||
{/* 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"
|
||||
className="ml-1 rounded-full p-0.5 text-[var(--danger)] opacity-0 transition-opacity hover:bg-[var(--danger-soft)] group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteDuty(duty.id);
|
||||
@@ -186,8 +194,10 @@ export function DutyManager({
|
||||
</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>
|
||||
<div className="flex flex-col gap-2 border-t border-[var(--hairline-soft)] pt-4">
|
||||
<h4 className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
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}>
|
||||
|
||||
@@ -163,7 +163,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
<Dialog onOpenChange={(open) => !open && resetState()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Details</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -189,11 +189,13 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
|
||||
<fieldset className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<legend className="text-sm font-semibold">Elternteile</legend>
|
||||
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Elternteile
|
||||
</legend>
|
||||
{parents.length < 2 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="pill"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
onClick={addParent}
|
||||
@@ -206,9 +208,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
</div>
|
||||
|
||||
{parents.map((parent, index) => (
|
||||
<div key={parent.id} className="rounded-md border p-3">
|
||||
<div
|
||||
key={parent.id}
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Elternteil {index + 1}
|
||||
</p>
|
||||
{parent.kind === "new" && (
|
||||
@@ -216,7 +221,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
|
||||
className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => removeNewParent(parent)}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -254,10 +259,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
|
||||
<fieldset className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<legend className="text-sm font-semibold">Kinder</legend>
|
||||
<legend className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kinder
|
||||
</legend>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="pill"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
onClick={addChild}
|
||||
@@ -269,21 +276,24 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||
</div>
|
||||
|
||||
{children.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-5 text-sm italic text-[var(--ink-faint)]">
|
||||
Keine Kinder verknüpft.
|
||||
</p>
|
||||
) : (
|
||||
children.map((child, index) => (
|
||||
<div key={child.id} className="rounded-md border p-3">
|
||||
<div
|
||||
key={child.id}
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
Kind {index + 1}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-destructive hover:text-destructive"
|
||||
className="h-7 gap-1 px-2 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => removeChild(child)}
|
||||
disabled={isPending}
|
||||
>
|
||||
|
||||
@@ -2,18 +2,7 @@ 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";
|
||||
import { EditFamilyDialog } from "./edit-family-dialog";
|
||||
import { FamiliesListClient } from "./_components/families-list-client";
|
||||
|
||||
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
|
||||
|
||||
@@ -31,7 +20,6 @@ export default async function FamiliesPage() {
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
// Alle Haushalte der Kita mit Elternteilen und Kindern laden.
|
||||
// kitaId IMMER aus der Session — nie aus URL/Params
|
||||
const families = await prisma.family.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
@@ -80,154 +68,15 @@ export default async function FamiliesPage() {
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const childCount = families.reduce(
|
||||
(acc, family) => acc + family.children.length,
|
||||
0,
|
||||
);
|
||||
const canManageDuties = session.user.role === UserRole.ADMIN;
|
||||
|
||||
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"} · ${childCount} Kinder`}
|
||||
</p>
|
||||
</div>
|
||||
<AddFamilyDialog />
|
||||
</div>
|
||||
|
||||
{families.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Familie</TableHead>
|
||||
<TableHead>Elternteile</TableHead>
|
||||
<TableHead>Kinder</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{families.map((family) => {
|
||||
const hasPendingInvite = family.users.some(
|
||||
(user) => !user.emailVerifiedAt,
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={family.id}>
|
||||
<TableCell className="font-medium">
|
||||
{family.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1.5">
|
||||
{family.users.map((parent) => (
|
||||
<div key={parent.id}>
|
||||
<div className="font-medium">
|
||||
{parent.firstName} {parent.lastName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{parent.email}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{family.children.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{family.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={hasPendingInvite ? "warning" : "success"}>
|
||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
<EditFamilyDialog
|
||||
family={{
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
parents: family.users.map((parent) => ({
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
email: parent.email,
|
||||
})),
|
||||
children: family.children,
|
||||
}}
|
||||
/>
|
||||
{canManageDuties &&
|
||||
family.users.map((parent) => (
|
||||
<DutyManager
|
||||
key={parent.id}
|
||||
user={{
|
||||
id: parent.id,
|
||||
firstName: parent.firstName,
|
||||
lastName: parent.lastName,
|
||||
}}
|
||||
allDuties={allDuties}
|
||||
userAssignments={parent.dutyAssignments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</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 className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<FamiliesListClient
|
||||
initialFamilies={families}
|
||||
allDuties={allDuties}
|
||||
canManageDuties={canManageDuties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import type { ComponentProps, FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { RsvpMode, TerminType } from "@prisma/client";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,29 +19,171 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createTerminAdmin } from "../actions";
|
||||
import { createTerminAdmin, updateTerminAdmin } from "../actions";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function AdminTerminModal() {
|
||||
type AdminTerminFormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
type: TerminType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: string;
|
||||
participantInfo: string;
|
||||
mitbringselListEnabled: boolean;
|
||||
};
|
||||
|
||||
const initialFormState: AdminTerminFormState = {
|
||||
title: "",
|
||||
description: "",
|
||||
type: TerminType.KITA_FEST,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
allDay: false,
|
||||
requiresRsvp: false,
|
||||
rsvpMode: RsvpMode.FAMILY,
|
||||
rsvpDeadline: "",
|
||||
participantInfo: "",
|
||||
mitbringselListEnabled: false,
|
||||
};
|
||||
|
||||
type AdminTerminModalProps = {
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: ComponentProps<typeof Button>["variant"];
|
||||
triggerClassName?: string;
|
||||
existingTermin?: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
type: TerminType;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allDay: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: Date | null;
|
||||
participantInfo: string | null;
|
||||
mitbringselListEnabled: boolean;
|
||||
hasRsvpResponses: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function AdminTerminModal({
|
||||
triggerLabel = "Termin direkt anlegen",
|
||||
triggerVariant,
|
||||
triggerClassName = "gap-2",
|
||||
existingTermin,
|
||||
}: AdminTerminModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formState, setFormState] =
|
||||
useState<AdminTerminFormState>(initialFormState);
|
||||
const [modeTouched, setModeTouched] = useState(false);
|
||||
const modeLocked = existingTermin?.hasRsvpResponses ?? false;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
||||
|
||||
// Initialize and reset form fields when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (existingTermin) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFormState({
|
||||
title: existingTermin.title,
|
||||
description: existingTermin.description || "",
|
||||
type: existingTermin.type,
|
||||
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
|
||||
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
|
||||
allDay: existingTermin.allDay,
|
||||
requiresRsvp: existingTermin.requiresRsvp,
|
||||
rsvpMode: existingTermin.rsvpMode,
|
||||
rsvpDeadline: existingTermin.rsvpDeadline
|
||||
? getLocalDateTimeInputValue(existingTermin.rsvpDeadline)
|
||||
: "",
|
||||
participantInfo: existingTermin.participantInfo ?? "",
|
||||
mitbringselListEnabled: existingTermin.mitbringselListEnabled,
|
||||
});
|
||||
} else {
|
||||
setFormState(initialFormState);
|
||||
}
|
||||
setModeTouched(false);
|
||||
}
|
||||
}, [open, existingTermin]);
|
||||
|
||||
const updateField = <TKey extends keyof AdminTerminFormState>(
|
||||
key: TKey,
|
||||
value: AdminTerminFormState[TKey],
|
||||
) => {
|
||||
setFormState((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === "type" && !existingTermin && !modeTouched && !modeLocked)
|
||||
next.rsvpMode = suggestedRsvpMode(value as TerminType);
|
||||
if (
|
||||
key === "startDate" &&
|
||||
typeof value === "string" &&
|
||||
next.endDate &&
|
||||
next.endDate < value
|
||||
) {
|
||||
next.endDate = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!formState.startDate || !formState.endDate) {
|
||||
toast.error("Bitte Start und Ende ausfüllen.");
|
||||
return;
|
||||
}
|
||||
// Only enforce future start date when creating a new event
|
||||
if (!existingTermin && formState.startDate < minDateTime) {
|
||||
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
|
||||
return;
|
||||
}
|
||||
if (formState.endDate < formState.startDate) {
|
||||
toast.error("Das Ende darf nicht vor dem Start liegen.");
|
||||
return;
|
||||
}
|
||||
|
||||
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",
|
||||
title: formState.title,
|
||||
description: formState.description,
|
||||
type: formState.type,
|
||||
startDate: new Date(formState.startDate).toISOString(),
|
||||
endDate: new Date(formState.endDate).toISOString(),
|
||||
allDay: formState.allDay,
|
||||
requiresRsvp: formState.requiresRsvp,
|
||||
rsvpMode: formState.rsvpMode,
|
||||
rsvpDeadline: formState.rsvpDeadline
|
||||
? new Date(formState.rsvpDeadline).toISOString()
|
||||
: null,
|
||||
participantInfo: formState.participantInfo,
|
||||
mitbringselListEnabled: formState.mitbringselListEnabled,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await createTerminAdmin(data);
|
||||
let res;
|
||||
if (existingTermin) {
|
||||
res = await updateTerminAdmin(existingTermin.id, data);
|
||||
} else {
|
||||
res = await createTerminAdmin(data);
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Termin wurde direkt angelegt!");
|
||||
toast.success(
|
||||
existingTermin
|
||||
? "Termin wurde erfolgreich aktualisiert!"
|
||||
: "Termin wurde direkt angelegt!",
|
||||
);
|
||||
if (!existingTermin) {
|
||||
setFormState(initialFormState);
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
@@ -48,29 +192,50 @@ export function AdminTerminModal() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 bg-primary">
|
||||
<Plus className="h-4 w-4" />
|
||||
Termin direkt anlegen
|
||||
<Button variant={triggerVariant} className={triggerClassName}>
|
||||
{!existingTermin && <Plus className="h-4 w-4" />}
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleAction}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin anlegen (Admin)</DialogTitle>
|
||||
<DialogTitle>
|
||||
{existingTermin
|
||||
? "Termin bearbeiten (Admin)"
|
||||
: "Termin anlegen (Admin)"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Dieser Termin wird sofort freigegeben und ist für alle sichtbar.
|
||||
{existingTermin
|
||||
? "Nimm Änderungen an diesem Termin vor. Die Änderungen werden sofort wirksam."
|
||||
: "Dieser Termin wird sofort freigegeben und ist für alle sichtbar."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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" />
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="z.B. Sommerfest"
|
||||
value={formState.title}
|
||||
onChange={(event) => updateField("title", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Beschreibung (Optional)</Label>
|
||||
<Textarea id="description" name="description" placeholder="Details zum Termin..." />
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Details zum Termin..."
|
||||
value={formState.description}
|
||||
onChange={(event) =>
|
||||
updateField("description", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@@ -78,18 +243,30 @@ export function AdminTerminModal() {
|
||||
<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"
|
||||
value={formState.type}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
onChange={(event) =>
|
||||
updateField("type", event.target.value as TerminType)
|
||||
}
|
||||
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.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.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.GEBURTSTAG_INTERN}>
|
||||
Geburtstag (Intern)
|
||||
</option>
|
||||
<option value={TerminType.GEBURTSTAG_EXTERN}>
|
||||
Raumanfrage (Extern)
|
||||
</option>
|
||||
<option value={TerminType.SONSTIGES}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -97,16 +274,111 @@ export function AdminTerminModal() {
|
||||
<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 />
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="datetime-local"
|
||||
min={existingTermin ? undefined : minDateTime}
|
||||
value={formState.startDate}
|
||||
onChange={(event) =>
|
||||
updateField("startDate", event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="datetime-local" required />
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
min={
|
||||
formState.startDate ||
|
||||
(existingTermin ? undefined : minDateTime)
|
||||
}
|
||||
value={formState.endDate}
|
||||
onChange={(event) =>
|
||||
updateField("endDate", event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.mitbringselListEnabled}
|
||||
onChange={(e) =>
|
||||
updateField("mitbringselListEnabled", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Mitbring-Liste aktiv
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.requiresRsvp}
|
||||
onChange={(e) => updateField("requiresRsvp", e.target.checked)}
|
||||
/>
|
||||
Teilnahme-Abfrage aktivieren
|
||||
</label>
|
||||
{formState.requiresRsvp ? (
|
||||
<div className="grid gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<Label htmlFor="calendar-rsvp-mode">Antwortart</Label>
|
||||
<select
|
||||
id="calendar-rsvp-mode"
|
||||
value={formState.rsvpMode}
|
||||
disabled={modeLocked}
|
||||
onChange={(e) => {
|
||||
updateField("rsvpMode", e.target.value as RsvpMode);
|
||||
setModeTouched(true);
|
||||
}}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
|
||||
>
|
||||
<option value="FAMILY">
|
||||
Pro Familie · mit Anzahl Erwachsener
|
||||
</option>
|
||||
<option value="CHILD">Pro Kind · einzelne Zu-/Absage</option>
|
||||
</select>
|
||||
{modeLocked ? (
|
||||
<p className="text-xs">
|
||||
Die Antwortart ist wegen vorhandener Rückmeldungen gesperrt.
|
||||
</p>
|
||||
) : null}
|
||||
<Label htmlFor="calendar-rsvp-deadline">
|
||||
Rückmeldefrist (sonst Terminbeginn)
|
||||
</Label>
|
||||
<Input
|
||||
id="calendar-rsvp-deadline"
|
||||
type="datetime-local"
|
||||
value={formState.rsvpDeadline}
|
||||
onChange={(e) => updateField("rsvpDeadline", e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="calendar-participant-info">
|
||||
Hinweise für Teilnehmer
|
||||
</Label>
|
||||
<Textarea
|
||||
id="calendar-participant-info"
|
||||
value={formState.participantInfo}
|
||||
onChange={(e) =>
|
||||
updateField("participantInfo", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
name="allDay"
|
||||
checked={formState.allDay}
|
||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
onChange={(event) =>
|
||||
updateField("allDay", event.target.checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
</Label>
|
||||
@@ -114,11 +386,19 @@ export function AdminTerminModal() {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Speichern..." : "Termin erstellen"}
|
||||
{isPending
|
||||
? "Speichern..."
|
||||
: existingTermin
|
||||
? "Änderungen speichern"
|
||||
: "Termin erstellen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -126,3 +406,10 @@ export function AdminTerminModal() {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getLocalDateTimeInputValue(date: Date) {
|
||||
const localDate = new Date(date);
|
||||
localDate.setSeconds(0, 0);
|
||||
const offset = localDate.getTimezoneOffset() * 60_000;
|
||||
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,23 +55,25 @@ export function MitbringselList({
|
||||
};
|
||||
|
||||
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" />
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-[var(--surface-2)] p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-sm font-medium text-[var(--ink)]">
|
||||
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
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>
|
||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||
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"
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-xs text-primary">
|
||||
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
||||
{item.user.firstName} {item.user.lastName}
|
||||
</span>
|
||||
<span>{item.content}</span>
|
||||
@@ -80,11 +82,11 @@ export function MitbringselList({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
|
||||
className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,7 +114,7 @@ export function MitbringselList({
|
||||
onClick={handleAdd}
|
||||
disabled={!content.trim() || isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
import { useTransition } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Check, X, Clock, CalendarIcon } from "lucide-react";
|
||||
import { CalendarIcon, Check, Clock, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { approveTermin, rejectTermin } from "../actions";
|
||||
|
||||
export type PendingTerminDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: Date;
|
||||
createdAt: Date;
|
||||
ageInDays: number;
|
||||
allDay: boolean;
|
||||
createdBy: { firstName: string; lastName: string } | null;
|
||||
};
|
||||
@@ -21,22 +24,28 @@ export type PendingTerminDto = {
|
||||
export function PendingAnfragen({ termine }: { termine: PendingTerminDto[] }) {
|
||||
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>
|
||||
<Card>
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center p-8 text-center animate-in fade-in-50">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<CalendarIcon className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[var(--ink)]">
|
||||
Keine ausstehenden Anfragen
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[var(--ink-soft)]">
|
||||
Es gibt aktuell keine Terminanfragen, die bestätigt werden müssen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="overflow-hidden p-0">
|
||||
{termine.map((termin) => (
|
||||
<PendingTerminCard key={termin.id} termin={termin} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,40 +76,57 @@ function PendingTerminCard({ termin }: { termin: PendingTerminDto }) {
|
||||
};
|
||||
|
||||
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="grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 border-b border-[var(--hairline-soft)] px-5 py-4 last:border-b-0 hover:bg-[var(--surface-2)]">
|
||||
<div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
|
||||
<span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
|
||||
{format(termin.startDate, "d", { locale: de })}
|
||||
</span>
|
||||
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
|
||||
{format(termin.startDate, "EEE", { locale: de })}
|
||||
</span>
|
||||
</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 className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{termin.title}
|
||||
</h3>
|
||||
{termin.ageInDays > 7 && (
|
||||
<Badge variant="warning">Älter als 7 Tage</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-1 flex items-center gap-1 text-sm text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(termin.startDate, "PP", { locale: de })}
|
||||
{!termin.allDay &&
|
||||
` · ${format(termin.startDate, "p", { locale: de })}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--accent-deep)]">
|
||||
Angefragt von: {termin.createdBy?.firstName}{" "}
|
||||
{termin.createdBy?.lastName} · vor {termin.ageInDays} Tagen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={handleReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Genehmigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
DutyAssignmentStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
} from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { AlignLeft, Calendar, Clock, WashingMachine } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
Clock,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
WashingMachine,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { RsvpForm } from "@/components/rsvp-form";
|
||||
import type { RsvpEventDto } from "@/lib/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AdminTerminModal } from "./admin-termin-modal";
|
||||
import { TerminRequestModal } from "./termin-request-modal";
|
||||
import { MitbringselList } from "./mitbringsel-list";
|
||||
import { createNotdienstSwapRequest, deleteTerminAdmin } from "../actions";
|
||||
|
||||
export type TerminListItemDto = {
|
||||
kind: "termin";
|
||||
id: string;
|
||||
@@ -25,6 +50,13 @@ export type TerminListItemDto = {
|
||||
endDate: Date;
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: Date | null;
|
||||
hasRsvpResponses: boolean;
|
||||
rsvpEvent?: RsvpEventDto;
|
||||
participantInfo: string | null;
|
||||
showParticipantInfo: boolean;
|
||||
mitbringselItems?: {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -41,83 +73,200 @@ export type DutyCalendarItemDto = {
|
||||
status: DutyAssignmentStatus;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isOwn: boolean;
|
||||
};
|
||||
|
||||
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
|
||||
export type NotdienstCalendarItemDto = {
|
||||
kind: "notdienst";
|
||||
id: string;
|
||||
planId: string;
|
||||
planYear: number;
|
||||
planMonth: number;
|
||||
title: string;
|
||||
familyId: string;
|
||||
familyName: string;
|
||||
childName: string;
|
||||
isOwn: boolean;
|
||||
canRequestSwap: boolean;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
export type NotdienstSwapOptionDto = {
|
||||
id: string;
|
||||
date: Date;
|
||||
childName: string;
|
||||
familyName: string;
|
||||
};
|
||||
|
||||
export type CalendarListItemDto =
|
||||
| TerminListItemDto
|
||||
| DutyCalendarItemDto
|
||||
| NotdienstCalendarItemDto;
|
||||
|
||||
export function TerminList({
|
||||
termine,
|
||||
userId,
|
||||
isAdmin,
|
||||
notdienstSwapOptions = [],
|
||||
}: {
|
||||
termine: CalendarListItemDto[];
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
notdienstSwapOptions?: NotdienstSwapOptionDto[];
|
||||
}) {
|
||||
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.
|
||||
<Card>
|
||||
<CardContent className="flex max-h-[280px] min-h-[260px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Calendar className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Keine Termine in diesem Zeitraum
|
||||
</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Lege den ersten Termin an, damit Eltern planen können.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{isAdmin ? (
|
||||
<AdminTerminModal
|
||||
triggerLabel="+ Termin anlegen"
|
||||
triggerClassName=""
|
||||
/>
|
||||
) : (
|
||||
<TerminRequestModal
|
||||
triggerLabel="+ Termin anlegen"
|
||||
triggerVariant="default"
|
||||
triggerClassName=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupByMonth(termine);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{termine.map((termin) => (
|
||||
termin.kind === "duty" ? (
|
||||
<DutyCard key={`duty-${termin.id}`} duty={termin} />
|
||||
) : (
|
||||
<TerminCard
|
||||
key={`termin-${termin.id}`}
|
||||
termin={termin}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => (
|
||||
<section key={group.key}>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
{group.label}
|
||||
</h2>
|
||||
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
{group.items.length}{" "}
|
||||
{group.items.length === 1 ? "Termin" : "Termine"}
|
||||
</span>
|
||||
</div>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{group.items.map((item) =>
|
||||
item.kind === "duty" ? (
|
||||
<DutyRow
|
||||
key={`duty-${item.id}`}
|
||||
duty={item}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : item.kind === "notdienst" ? (
|
||||
<NotdienstRow
|
||||
key={`notdienst-${item.id}`}
|
||||
notdienst={item}
|
||||
isAdmin={isAdmin}
|
||||
notdienstSwapOptions={notdienstSwapOptions}
|
||||
/>
|
||||
) : (
|
||||
<TerminRow
|
||||
key={`termin-${item.id}`}
|
||||
termin={item}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DutyCard({ duty }: { duty: DutyCalendarItemDto }) {
|
||||
function DutyRow({
|
||||
duty,
|
||||
isAdmin,
|
||||
}: {
|
||||
duty: DutyCalendarItemDto;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden border-emerald-200 bg-emerald-50/60">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<Badge className="border-transparent bg-emerald-600 text-white hover:bg-emerald-700">
|
||||
Elterndienst
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="line-clamp-2 leading-tight">
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={duty.startDate} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{duty.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 flex items-center gap-1 text-sm">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
</h3>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(duty.startDate, "dd.MM.", { locale: de })} -{" "}
|
||||
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-4">
|
||||
<div className="flex items-start gap-2 text-sm text-emerald-900">
|
||||
<WashingMachine className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
Eingeteilt: <span className="font-medium">{duty.familyName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WashingMachine className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{duty.familyName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<CategoryPill color={color}>
|
||||
{duty.isOwn ? "Mein Elterndienst" : "Elterndienst"}
|
||||
</CategoryPill>
|
||||
{isAdmin ? (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Mehr Optionen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<a
|
||||
href="/dashboard/admin/dienste"
|
||||
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Dienst im Planer bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-9 h-9" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminCard({
|
||||
function TerminRow({
|
||||
termin,
|
||||
userId,
|
||||
isAdmin,
|
||||
@@ -126,125 +275,279 @@ function TerminCard({
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const color = getTypeColor(termin.type);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const handleToggleMitbringsel = (enabled: boolean) => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleMitbringselList(termin.id, enabled);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
const handleDeleteConfirm = () => {
|
||||
startDeleteTransition(async () => {
|
||||
const res = await deleteTerminAdmin(termin.id);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success(
|
||||
enabled
|
||||
? "Mitbring-Liste aktiviert"
|
||||
: "Mitbring-Liste deaktiviert"
|
||||
);
|
||||
toast.success("Termin wurde erfolgreich gelöscht.");
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
<>
|
||||
<Dialog>
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={termin.startDate} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{termin.title}
|
||||
</h3>
|
||||
{termin.status === TerminStatus.PENDING && (
|
||||
<Badge variant="warning">Ausstehend</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{formatTerminTime(termin)}
|
||||
<MapPin className="ml-1 h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
Kita
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<CategoryPill color={color}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</CategoryPill>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
Details
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{isAdmin ? (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Mehr Optionen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<AdminTerminModal
|
||||
existingTermin={termin}
|
||||
triggerLabel="Bearbeiten"
|
||||
triggerVariant="ghost"
|
||||
triggerClassName="flex w-full items-center justify-start rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)] font-normal h-auto bg-transparent border-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-soft)] text-left font-normal"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-9 h-9" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{termin.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{format(termin.startDate, "PP", { locale: de })}
|
||||
{!termin.allDay &&
|
||||
` · ${format(termin.startDate, "p", { locale: de })} - ${format(
|
||||
termin.endDate,
|
||||
"p",
|
||||
{ locale: de },
|
||||
)}`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<div className="grid gap-4">
|
||||
<div className="flex">
|
||||
<CategoryPill color={color}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</CategoryPill>
|
||||
</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}
|
||||
/>
|
||||
{termin.description ? (
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{termin.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-[var(--ink-faint)]">
|
||||
Keine Beschreibung hinterlegt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{termin.rsvpEvent ? (
|
||||
<section className="rounded-lg border border-[var(--hairline)] p-4">
|
||||
<h3 className="mb-3 font-medium">Eure Rückmeldung</h3>
|
||||
<RsvpForm
|
||||
key={JSON.stringify(termin.rsvpEvent)}
|
||||
event={termin.rsvpEvent}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{termin.participantInfo && termin.showParticipantInfo && (
|
||||
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="h-4 w-4 text-[var(--warn)] [stroke-width:1.7]" />
|
||||
Wichtiger Hinweis für Teilnehmer
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{termin.participantInfo}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{termin.mitbringselListEnabled && (
|
||||
<div className="mt-3 border-t border-[var(--hairline-soft)] pt-3">
|
||||
<MitbringselList
|
||||
terminId={termin.id}
|
||||
items={termin.mitbringselItems || []}
|
||||
currentUserId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Termin “{termin.title}” wirklich
|
||||
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||
gehen verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Wird gelöscht..." : "Löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 DateTile({ date }: { date: Date }) {
|
||||
return (
|
||||
<div className="flex h-14 w-14 flex-col items-center justify-center rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)]">
|
||||
<span className="text-[22px] font-medium leading-none tracking-[-0.02em] text-[var(--ink)] tabular-nums">
|
||||
{format(date, "d", { locale: de })}
|
||||
</span>
|
||||
<span className="mt-1 text-[10px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.08em]">
|
||||
{format(date, "EEE", { locale: de })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getBadgeColor(type: TerminType): string {
|
||||
function CategoryPill({
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
|
||||
style={{ color }}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByMonth(items: CalendarListItemDto[]) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ key: string; label: string; items: CalendarListItemDto[] }
|
||||
>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const key = format(item.startDate, "yyyy-MM");
|
||||
const label = format(item.startDate, "MMMM yyyy", { locale: de });
|
||||
const group = groups.get(key) ?? { key, label, items: [] };
|
||||
group.items.push(item);
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
function formatTerminTime(termin: TerminListItemDto) {
|
||||
if (termin.allDay) return "ganztägig";
|
||||
return `${format(termin.startDate, "HH:mm", { locale: de })} - ${format(
|
||||
termin.endDate,
|
||||
"HH:mm",
|
||||
{ locale: de },
|
||||
)}`;
|
||||
}
|
||||
|
||||
function getTypeColor(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";
|
||||
return "var(--good)";
|
||||
case TerminType.SCHLIESSTAG:
|
||||
case TerminType.TEAMTAG:
|
||||
return "bg-rose-500 hover:bg-rose-600 text-white border-transparent";
|
||||
return "var(--danger)";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "bg-blue-500 hover:bg-blue-600 text-white border-transparent";
|
||||
return "oklch(0.50 0.10 320)";
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "bg-purple-500 hover:bg-purple-600 text-white border-transparent";
|
||||
return "oklch(0.50 0.10 280)";
|
||||
default:
|
||||
return "bg-slate-200 text-slate-800 border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700";
|
||||
return "var(--ink-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +560,7 @@ function getTypeLabel(type: TerminType): string {
|
||||
case TerminType.SCHLIESSTAG:
|
||||
return "Schließtag";
|
||||
case TerminType.TEAMTAG:
|
||||
return "Teamtag (Kita geschlossen)";
|
||||
return "Teamtag";
|
||||
case TerminType.ELTERNABEND:
|
||||
return "Elternabend";
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
@@ -265,10 +568,216 @@ function getTypeLabel(type: TerminType): string {
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "Elterncafe";
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
return "Geburtstag (Intern)";
|
||||
return "Geburtstag";
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "Raumanfrage (Extern)";
|
||||
return "Raumanfrage";
|
||||
default:
|
||||
return "Sonstiges";
|
||||
}
|
||||
}
|
||||
|
||||
export function NotdienstSwapDialog({
|
||||
target,
|
||||
options,
|
||||
trigger,
|
||||
}: {
|
||||
target: NotdienstCalendarItemDto;
|
||||
options: NotdienstSwapOptionDto[];
|
||||
trigger: ReactNode;
|
||||
}) {
|
||||
const selectableOptions = options.filter((option) => option.id !== target.id);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [offeredAssignmentId, setOfferedAssignmentId] = useState(
|
||||
selectableOptions[0]?.id ?? "",
|
||||
);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!offeredAssignmentId) {
|
||||
toast.error("Bitte wähle einen eigenen Notdienst als Tausch-Angebot.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createNotdienstSwapRequest({
|
||||
targetAssignmentId: target.id,
|
||||
offeredAssignmentId,
|
||||
message,
|
||||
});
|
||||
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Tauschanfrage wurde versendet.");
|
||||
setOpen(false);
|
||||
setMessage("");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notdienst tauschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
|
||||
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen
|
||||
könnt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[var(--ink)]">
|
||||
Dein Tausch-Angebot
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
value={offeredAssignmentId}
|
||||
disabled={isPending || selectableOptions.length === 0}
|
||||
onChange={(event) => setOfferedAssignmentId(event.target.value)}
|
||||
>
|
||||
{selectableOptions.length === 0 ? (
|
||||
<option value="">
|
||||
Kein eigener zukünftiger Notdienst verfügbar
|
||||
</option>
|
||||
) : (
|
||||
selectableOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{format(option.date, "dd.MM.yyyy", { locale: de })} -{" "}
|
||||
{option.childName}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[var(--ink)]">
|
||||
Nachricht optional
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--ink)] ring-offset-[var(--surface)] placeholder:text-[var(--ink-muted)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
placeholder="Zum Beispiel: Wir sind an dem Tag verreist."
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || selectableOptions.length === 0}
|
||||
>
|
||||
Anfrage senden
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NotdienstRow({
|
||||
notdienst,
|
||||
isAdmin,
|
||||
notdienstSwapOptions,
|
||||
}: {
|
||||
notdienst: NotdienstCalendarItemDto;
|
||||
isAdmin: boolean;
|
||||
notdienstSwapOptions: NotdienstSwapOptionDto[];
|
||||
}) {
|
||||
const color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={notdienst.startDate} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
{notdienst.title}
|
||||
</h3>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })}{" "}
|
||||
(ganztägig)
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
|
||||
Kind: {notdienst.childName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<CategoryPill color={color}>
|
||||
{notdienst.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||
</CategoryPill>
|
||||
{isAdmin ? (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Mehr Optionen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<a
|
||||
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
|
||||
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Notdienst im Planer bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : notdienst.canRequestSwap ? (
|
||||
<NotdienstSwapDialog
|
||||
target={notdienst}
|
||||
options={notdienstSwapOptions}
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2.5 text-xs"
|
||||
>
|
||||
Tausch anfragen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import type { ComponentProps, FormEvent } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { CalendarPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,17 +20,74 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createTerminRequest } from "../actions";
|
||||
|
||||
export function TerminRequestModal() {
|
||||
type TerminRequestFormState = {
|
||||
title: string;
|
||||
type: TerminType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
};
|
||||
|
||||
const initialFormState: TerminRequestFormState = {
|
||||
title: "",
|
||||
type: TerminType.KITA_FEST,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
allDay: false,
|
||||
};
|
||||
|
||||
type TerminRequestModalProps = {
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: ComponentProps<typeof Button>["variant"];
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export function TerminRequestModal({
|
||||
triggerLabel = "Termin anfragen",
|
||||
triggerVariant = "ghost",
|
||||
triggerClassName = "gap-2",
|
||||
}: TerminRequestModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<TerminRequestFormState>(initialFormState);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
||||
const minEndDateTime = formState.startDate || minDateTime;
|
||||
|
||||
const updateField = <TKey extends keyof TerminRequestFormState>(
|
||||
key: TKey,
|
||||
value: TerminRequestFormState[TKey],
|
||||
) => {
|
||||
setFormState((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
|
||||
next.endDate = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!formState.startDate || !formState.endDate) {
|
||||
toast.error("Bitte Start und Ende ausfüllen.");
|
||||
return;
|
||||
}
|
||||
if (formState.startDate < minDateTime) {
|
||||
toast.error("Der Startzeitpunkt darf nicht in der Vergangenheit liegen.");
|
||||
return;
|
||||
}
|
||||
if (formState.endDate < formState.startDate) {
|
||||
toast.error("Das Ende darf nicht vor dem Start liegen.");
|
||||
return;
|
||||
}
|
||||
|
||||
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",
|
||||
title: formState.title,
|
||||
type: formState.type,
|
||||
startDate: new Date(formState.startDate).toISOString(),
|
||||
endDate: new Date(formState.endDate).toISOString(),
|
||||
allDay: formState.allDay,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -38,6 +96,7 @@ export function TerminRequestModal() {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Terminanfrage wurde erfolgreich gestellt!");
|
||||
setFormState(initialFormState);
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
@@ -46,13 +105,13 @@ export function TerminRequestModal() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
Termin anfragen
|
||||
<Button variant={triggerVariant} className={triggerClassName}>
|
||||
<CalendarPlus className="h-4 w-4 [stroke-width:1.7]" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleAction}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin anfragen</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -64,7 +123,14 @@ export function TerminRequestModal() {
|
||||
<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" />
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="z.B. Geburtstag von Mia"
|
||||
value={formState.title}
|
||||
onChange={(event) => updateField("title", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@@ -72,7 +138,9 @@ export function TerminRequestModal() {
|
||||
<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"
|
||||
value={formState.type}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onChange={(event) => updateField("type", event.target.value as TerminType)}
|
||||
required
|
||||
>
|
||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||
@@ -91,16 +159,39 @@ export function TerminRequestModal() {
|
||||
<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 />
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="datetime-local"
|
||||
min={minDateTime}
|
||||
value={formState.startDate}
|
||||
onChange={(event) => updateField("startDate", event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">Ende</Label>
|
||||
<Input id="endDate" name="endDate" type="datetime-local" required />
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
min={minEndDateTime}
|
||||
value={formState.endDate}
|
||||
onChange={(event) => updateField("endDate", event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" id="allDay" name="allDay" className="rounded border-gray-300" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
name="allDay"
|
||||
checked={formState.allDay}
|
||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
onChange={(event) => updateField("allDay", event.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
</Label>
|
||||
@@ -120,3 +211,10 @@ export function TerminRequestModal() {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getLocalDateTimeInputValue(date: Date) {
|
||||
const localDate = new Date(date);
|
||||
localDate.setSeconds(0, 0);
|
||||
const offset = localDate.getTimezoneOffset() * 60_000;
|
||||
return new Date(localDate.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,104 @@
|
||||
import { DutyAssignmentStatus, TerminStatus, UserRole } from "@prisma/client";
|
||||
import {
|
||||
DutyAssignmentStatus,
|
||||
EventParticipationStatus,
|
||||
NotdienstSwapStatus,
|
||||
TerminStatus,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
|
||||
import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
|
||||
import {
|
||||
PendingAnfragen,
|
||||
type PendingTerminDto,
|
||||
} from "./_components/pending-anfragen";
|
||||
import {
|
||||
type CalendarListItemDto,
|
||||
type NotdienstSwapOptionDto,
|
||||
} from "./_components/termin-list";
|
||||
import { TerminRequestModal } from "./_components/termin-request-modal";
|
||||
import {
|
||||
CalendarOverviewClient,
|
||||
type NotdienstSwapRequestDto,
|
||||
} from "./_components/calendar-overview-client";
|
||||
|
||||
export const metadata = { title: "Terminkalender · Kita-Planer" };
|
||||
|
||||
export default async function KalenderPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { tab?: string };
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const session = await requireKitaSession();
|
||||
const isAdmin =
|
||||
session.user.role === UserRole.ADMIN ||
|
||||
session.user.role === UserRole.KOORDINATOR;
|
||||
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
|
||||
|
||||
const currentTab = searchParams.tab || "übersicht";
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const currentTab = resolvedSearchParams.tab || "übersicht";
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
const confirmedTermineRows = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
const [rsvpEventRows, confirmedTermineRows] = await Promise.all([
|
||||
getRsvpEvents(session),
|
||||
prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
rsvpDeadline: true,
|
||||
_count: {
|
||||
select: { participations: true, familyParticipations: true },
|
||||
},
|
||||
familyParticipations: {
|
||||
where: { familyId: familyIdForParticipation },
|
||||
select: { status: true },
|
||||
},
|
||||
participantInfo: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
where: {
|
||||
child: {
|
||||
familyId: familyIdForParticipation,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
childId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const myPendingTermineRows = await prisma.termin.findMany({
|
||||
where: {
|
||||
@@ -67,6 +116,15 @@ export default async function KalenderPage({
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
rsvpDeadline: true,
|
||||
_count: { select: { participations: true, familyParticipations: true } },
|
||||
familyParticipations: {
|
||||
where: { familyId: familyIdForParticipation },
|
||||
select: { status: true },
|
||||
},
|
||||
participantInfo: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -80,6 +138,17 @@ export default async function KalenderPage({
|
||||
},
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
where: {
|
||||
child: {
|
||||
familyId: familyIdForParticipation,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
childId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
@@ -88,13 +157,14 @@ export default async function KalenderPage({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: DutyAssignmentStatus.PLANNED,
|
||||
endDate: { gte: new Date() },
|
||||
endDate: { gte: now },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
status: true,
|
||||
familyId: true,
|
||||
dutyType: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -109,29 +179,190 @@ export default async function KalenderPage({
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
|
||||
const notdienstAssignmentRows = await prisma.notdienstAssignment.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
plan: {
|
||||
status: "PUBLISHED",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
planId: true,
|
||||
plan: {
|
||||
select: {
|
||||
year: true,
|
||||
month: true,
|
||||
},
|
||||
},
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
familyId: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
|
||||
const pendingSwapRows =
|
||||
!isAdmin && session.user.familyId
|
||||
? await prisma.notdienstSwapRequest.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
OR: [
|
||||
{ requesterFamilyId: session.user.familyId },
|
||||
{
|
||||
targetAssignment: {
|
||||
child: {
|
||||
familyId: session.user.familyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
requesterFamilyId: true,
|
||||
message: true,
|
||||
targetAssignment: {
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
offeredAssignment: {
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const rsvpEvents = new Map(rsvpEventRows.map((event) => [event.id, event]));
|
||||
const mapTermin = (
|
||||
termin: (typeof confirmedTermineRows)[number],
|
||||
): CalendarListItemDto => {
|
||||
const showParticipantInfo =
|
||||
isAdmin ||
|
||||
termin.familyParticipations.some(
|
||||
(row) => row.status === EventParticipationStatus.ATTENDING,
|
||||
) ||
|
||||
termin.participations.some(
|
||||
(row) => row.status === EventParticipationStatus.ATTENDING,
|
||||
);
|
||||
const { _count, familyParticipations, ...data } = termin;
|
||||
void familyParticipations;
|
||||
return {
|
||||
...data,
|
||||
kind: "termin",
|
||||
showParticipantInfo,
|
||||
participantInfo: showParticipantInfo ? termin.participantInfo : null,
|
||||
hasRsvpResponses: _count.participations + _count.familyParticipations > 0,
|
||||
rsvpEvent: rsvpEvents.get(termin.id),
|
||||
};
|
||||
};
|
||||
const allUserTermine: CalendarListItemDto[] = [
|
||||
...confirmedTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
kind: "termin" as const,
|
||||
})),
|
||||
...myPendingTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
kind: "termin" as const,
|
||||
})),
|
||||
...dutyAssignmentRows.map((assignment) => ({
|
||||
kind: "duty" as const,
|
||||
id: assignment.id,
|
||||
title: assignment.dutyType.name,
|
||||
familyName: assignment.family.name,
|
||||
status: assignment.status,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
})),
|
||||
...confirmedTermineRows.map(mapTermin),
|
||||
...myPendingTermineRows.map(mapTermin),
|
||||
...dutyAssignmentRows.map((assignment) => {
|
||||
const isOwn = assignment.familyId === familyIdForParticipation;
|
||||
return {
|
||||
kind: "duty" as const,
|
||||
id: assignment.id,
|
||||
title: isOwn
|
||||
? `Mein Elterndienst (${assignment.dutyType.name})`
|
||||
: `${assignment.dutyType.name} (${assignment.family.name})`,
|
||||
familyName: assignment.family.name,
|
||||
status: assignment.status,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
isOwn,
|
||||
};
|
||||
}),
|
||||
...notdienstAssignmentRows.map((assignment) => {
|
||||
const isOwn = assignment.child.familyId === familyIdForParticipation;
|
||||
const canRequestSwap =
|
||||
!isAdmin &&
|
||||
!isOwn &&
|
||||
!!session.user.familyId &&
|
||||
assignment.date >= today;
|
||||
return {
|
||||
kind: "notdienst" as const,
|
||||
id: assignment.id,
|
||||
planId: assignment.planId,
|
||||
planYear: assignment.plan.year,
|
||||
planMonth: assignment.plan.month,
|
||||
title: isOwn
|
||||
? `Mein Notdienst (${assignment.child.firstName})`
|
||||
: `Notdienst (${assignment.child.family.name})`,
|
||||
familyId: assignment.child.familyId,
|
||||
familyName: assignment.child.family.name,
|
||||
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||
isOwn,
|
||||
canRequestSwap,
|
||||
startDate: assignment.date,
|
||||
endDate: assignment.date,
|
||||
};
|
||||
}),
|
||||
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
||||
|
||||
const notdienstSwapOptions: NotdienstSwapOptionDto[] = notdienstAssignmentRows
|
||||
.filter(
|
||||
(assignment) =>
|
||||
assignment.child.familyId === familyIdForParticipation &&
|
||||
assignment.date >= today,
|
||||
)
|
||||
.map((assignment) => ({
|
||||
id: assignment.id,
|
||||
date: assignment.date,
|
||||
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||
familyName: assignment.child.family.name,
|
||||
}));
|
||||
|
||||
const pendingSwapRequests: NotdienstSwapRequestDto[] = pendingSwapRows.map(
|
||||
(request) => ({
|
||||
id: request.id,
|
||||
direction:
|
||||
request.requesterFamilyId === session.user.familyId
|
||||
? "outgoing"
|
||||
: "incoming",
|
||||
targetDate: request.targetAssignment.date,
|
||||
targetFamilyName: request.targetAssignment.child.family.name,
|
||||
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
|
||||
offeredDate: request.offeredAssignment.date,
|
||||
offeredFamilyName: request.offeredAssignment.child.family.name,
|
||||
offeredChildName: `${request.offeredAssignment.child.firstName} ${request.offeredAssignment.child.lastName}`,
|
||||
message: request.message,
|
||||
}),
|
||||
);
|
||||
|
||||
let allPendingTermine: PendingTerminDto[] = [];
|
||||
if (isAdmin) {
|
||||
allPendingTermine = await prisma.termin.findMany({
|
||||
const pendingRows = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.PENDING,
|
||||
@@ -140,6 +371,8 @@ export default async function KalenderPage({
|
||||
id: true,
|
||||
title: true,
|
||||
startDate: true,
|
||||
createdAt: true,
|
||||
type: true,
|
||||
allDay: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
@@ -150,72 +383,159 @@ export default async function KalenderPage({
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
allPendingTermine = pendingRows.map((termin) => ({
|
||||
...termin,
|
||||
ageInDays: Math.floor(
|
||||
(now.getTime() - termin.createdAt.getTime()) / (1000 * 60 * 60 * 24),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
|
||||
<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 className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
TERMINKALENDER
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Was steht{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">an</em>
|
||||
?
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Alle anstehenden Termine, Feste, Dienste und Schließtage im
|
||||
Überblick.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<TerminRequestModal />
|
||||
<span className="group relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-muted)]">
|
||||
<HelpCircle className="h-4 w-4 [stroke-width:1.7]" />
|
||||
<span className="pointer-events-none absolute right-0 top-11 z-30 hidden w-72 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-xs leading-5 text-white shadow-lg group-hover:block">
|
||||
Anfragen müssen vom Vorstand bestätigt werden, direkte Anlage
|
||||
erscheint sofort im Kalender.
|
||||
</span>
|
||||
</span>
|
||||
{isAdmin && <AdminTerminModal />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{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={`flex items-center gap-2 pb-2 text-sm font-medium transition-colors hover:text-primary ${
|
||||
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="flex flex-col gap-5">
|
||||
<TabBar
|
||||
currentTab={currentTab}
|
||||
overviewCount={allUserTermine.length}
|
||||
pendingCount={allPendingTermine.length}
|
||||
/>
|
||||
|
||||
<div className="pt-2">
|
||||
<div>
|
||||
{currentTab === "übersicht" ? (
|
||||
<TerminList
|
||||
<CalendarOverviewClient
|
||||
termine={allUserTermine}
|
||||
userId={session.user.id}
|
||||
isAdmin={isAdmin}
|
||||
notdienstSwapOptions={notdienstSwapOptions}
|
||||
pendingSwapRequests={pendingSwapRequests}
|
||||
/>
|
||||
) : (
|
||||
) : currentTab === "anfragen" ? (
|
||||
<PendingAnfragen termine={allPendingTermine} />
|
||||
) : (
|
||||
<CalendarOverviewClient
|
||||
termine={[]}
|
||||
userId={session.user.id}
|
||||
isAdmin={isAdmin}
|
||||
notdienstSwapOptions={notdienstSwapOptions}
|
||||
pendingSwapRequests={pendingSwapRequests}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TerminList
|
||||
<CalendarOverviewClient
|
||||
termine={allUserTermine}
|
||||
userId={session.user.id}
|
||||
isAdmin={isAdmin}
|
||||
notdienstSwapOptions={notdienstSwapOptions}
|
||||
pendingSwapRequests={pendingSwapRequests}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBar({
|
||||
currentTab,
|
||||
overviewCount,
|
||||
pendingCount,
|
||||
}: {
|
||||
currentTab: string;
|
||||
overviewCount: number;
|
||||
pendingCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex border-b border-[var(--hairline)]">
|
||||
<TabLink
|
||||
href="/dashboard/kalender?tab=übersicht"
|
||||
active={currentTab === "übersicht"}
|
||||
count={overviewCount}
|
||||
>
|
||||
Übersicht
|
||||
</TabLink>
|
||||
<TabLink
|
||||
href="/dashboard/kalender?tab=anfragen"
|
||||
active={currentTab === "anfragen"}
|
||||
count={pendingCount}
|
||||
attention={pendingCount > 0}
|
||||
>
|
||||
Ausstehende Anfragen
|
||||
</TabLink>
|
||||
<TabLink
|
||||
href="/dashboard/kalender?tab=vergangen"
|
||||
active={currentTab === "vergangen"}
|
||||
>
|
||||
Vergangene
|
||||
</TabLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabLink({
|
||||
href,
|
||||
active,
|
||||
count,
|
||||
attention,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
count?: number;
|
||||
attention?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={[
|
||||
"-mb-px inline-flex h-11 items-center gap-2 border-b-2 px-1.5 text-sm transition first:pl-0",
|
||||
active
|
||||
? "border-[var(--accent)] font-semibold text-[var(--ink)]"
|
||||
: "border-transparent font-medium text-[var(--ink-soft)] hover:text-[var(--ink)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
{typeof count === "number" && (
|
||||
<span
|
||||
className={[
|
||||
"rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||
attention
|
||||
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]"
|
||||
: "border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Baby, LogOut } from "lucide-react";
|
||||
|
||||
import { signOut } from "@/auth";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
|
||||
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
@@ -16,11 +13,11 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// Dashboard-Layout · Linke Sidebar
|
||||
// Dashboard-Layout · Responsive App Shell
|
||||
// ---------------------------------------------------------------------
|
||||
// Server Component: liest kitaId aus der Session, holt den Kita-Namen
|
||||
// aus der DB und rendert das App-Shell (Sidebar + main content area).
|
||||
// Der eigentliche Seiteninhalt kommt via {children} rein.
|
||||
// aus der DB und rendert die responsive DashboardShell (mit Desktop-Sidebar
|
||||
// und Mobile-Drawer).
|
||||
// =====================================================================
|
||||
|
||||
export default async function DashboardLayout({
|
||||
@@ -34,63 +31,22 @@ export default async function DashboardLayout({
|
||||
where: { id: session.user.kitaId },
|
||||
select: { name: true },
|
||||
});
|
||||
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
|
||||
|
||||
async function handleSignOut() {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/" });
|
||||
}
|
||||
|
||||
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} />
|
||||
</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>
|
||||
<DashboardShell
|
||||
kitaName={kita.name}
|
||||
userName={displayName}
|
||||
userRole={session.user.role}
|
||||
signOutAction={handleSignOut}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getAnnouncements } from "@/lib/announcements";
|
||||
import { NewsTicker } from "../news-ticker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const metadata = { title: "Mitteilungen · Kita-Planer" };
|
||||
|
||||
export default async function MitteilungenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const session = await requireKitaSession();
|
||||
const params = await searchParams;
|
||||
const parsed = Number(params.page ?? 1);
|
||||
const page =
|
||||
Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, 100000) : 1;
|
||||
const pageSize = 12;
|
||||
const items = await getAnnouncements(session, {
|
||||
take: pageSize + 1,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||
SCHWARZES BRETT
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
|
||||
Alle{" "}
|
||||
<em className="font-normal text-[var(--accent-deep)]">
|
||||
Mitteilungen
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] text-[var(--ink-soft)]">
|
||||
Aktuelle und ältere Meldungen für deine Empfängergruppe.
|
||||
</p>
|
||||
</header>
|
||||
<NewsTicker items={items.slice(0, pageSize)} archive />
|
||||
<nav
|
||||
aria-label="Mitteilungen durchblättern"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
{page > 1 ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/dashboard/mitteilungen?page=${page - 1}`}>
|
||||
Neuere Meldungen
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--ink-soft)]">Seite {page}</span>
|
||||
<div>
|
||||
{items.length > pageSize ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/dashboard/mitteilungen?page=${page + 1}`}>
|
||||
Ältere Meldungen
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Bell, FileText, Megaphone } from "lucide-react";
|
||||
|
||||
@@ -26,6 +28,7 @@ export type NewsTickerItemDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
createdAt: string;
|
||||
authorName: string;
|
||||
isUnread: boolean;
|
||||
@@ -37,43 +40,66 @@ export type NewsTickerItemDto = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
export function NewsTicker({
|
||||
items,
|
||||
archive = false,
|
||||
}: {
|
||||
items: NewsTickerItemDto[];
|
||||
archive?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
|
||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function openAnnouncement(item: NewsTickerItemDto) {
|
||||
setSelected(item);
|
||||
if (!item.isUnread || readIds.has(item.id)) return;
|
||||
|
||||
setReadIds((current) => new Set(current).add(item.id));
|
||||
startTransition(async () => {
|
||||
await markAnnouncementRead(item.id);
|
||||
try {
|
||||
const result = await markAnnouncementRead(item.id);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setReadIds((current) => new Set(current).add(item.id));
|
||||
} catch {
|
||||
toast.error("Lesestatus konnte nicht gespeichert werden.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-8 border-emerald-200 bg-emerald-50/60">
|
||||
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Megaphone className="h-5 w-5 text-emerald-700" />
|
||||
Schwarzes Brett
|
||||
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
|
||||
{archive ? "Mitteilungen" : "Neues aus der Kita"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Die neuesten offiziellen Ankündigungen deiner Kita.
|
||||
{archive
|
||||
? "Veröffentlichte Ankündigungen deiner Kita."
|
||||
: "Die neuesten offiziellen Ankündigungen deiner Kita."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{items.length}</Badge>
|
||||
{archive ? (
|
||||
<Badge variant="secondary">{items.length} Meldungen</Badge>
|
||||
) : (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/dashboard/mitteilungen">Alle Mitteilungen</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="col-span-full py-4 text-sm text-[var(--ink-soft)]">
|
||||
Noch keine Mitteilungen für deine Empfängergruppe.
|
||||
</p>
|
||||
) : null}
|
||||
{items.map((item) => {
|
||||
const isUnread = item.isUnread && !readIds.has(item.id);
|
||||
return (
|
||||
@@ -81,26 +107,24 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => openAnnouncement(item)}
|
||||
className="rounded-md border bg-background p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-300 hover:shadow-md"
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 text-left transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--ink-muted)]">
|
||||
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{item.createdAt}
|
||||
</div>
|
||||
{isUnread && (
|
||||
<Badge className="bg-emerald-600 text-white">NEU</Badge>
|
||||
)}
|
||||
{isUnread && <Badge variant="success">NEU</Badge>}
|
||||
</div>
|
||||
<h3 className="line-clamp-2 font-semibold leading-snug">
|
||||
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
|
||||
{item.content}
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{item.excerpt}
|
||||
</p>
|
||||
{item.attachments.length > 0 && (
|
||||
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-emerald-700">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
|
||||
<FileText className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{item.attachments.length} Anhang
|
||||
{item.attachments.length === 1 ? "" : "e"}
|
||||
</p>
|
||||
@@ -111,7 +135,10 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<Dialog
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
{selected && (
|
||||
<>
|
||||
@@ -122,7 +149,7 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm">
|
||||
<MarkdownContent content={selected.content} />
|
||||
</div>
|
||||
|
||||
@@ -137,8 +164,12 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
|
||||
<FileText className="h-4 w-4" />
|
||||
<a
|
||||
href={attachment.fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<FileText className="h-4 w-4 [stroke-width:1.7]" />
|
||||
{attachment.fileName}
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -15,11 +15,23 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
||||
// Die Termine werden Round-Robin auf die Kinder aufgeteilt.
|
||||
// =====================================================================
|
||||
|
||||
const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
childrenIds: z.array(z.string()).min(1),
|
||||
dates: z.array(z.string().datetime()), // ISO strings
|
||||
dates: z.array(
|
||||
z.string().regex(dateKeyPattern).refine((value) => {
|
||||
const date = dateKeyToUtcDate(value);
|
||||
return value === date.toISOString().slice(0, 10);
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
function dateKeyToUtcDate(dateKey: string) {
|
||||
const [year, month, day] = dateKey.split("-").map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
childrenIds: string[];
|
||||
dates: string[];
|
||||
@@ -35,7 +47,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const { childrenIds, dates } = parsed.data;
|
||||
const { childrenIds } = parsed.data;
|
||||
const dates = Array.from(new Set(parsed.data.dates)).sort();
|
||||
|
||||
// ── 1. Sperrfrist prüfen ───────────────────────────────────────────
|
||||
const { targetYear, targetMonth, isLocked } = getTargetMonthData();
|
||||
@@ -79,9 +92,15 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
}
|
||||
|
||||
// Termine in Date-Objekte wandeln und sortieren
|
||||
const parsedDates = dates
|
||||
.map((d) => new Date(d))
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
const parsedDates = dates.map(dateKeyToUtcDate);
|
||||
const hasOutsideTargetMonth = parsedDates.some(
|
||||
(date) =>
|
||||
date.getUTCFullYear() !== targetYear ||
|
||||
date.getUTCMonth() !== targetMonth - 1,
|
||||
);
|
||||
if (hasOutsideTargetMonth) {
|
||||
return { error: "Bitte wähle Termine im Zielmonat aus." };
|
||||
}
|
||||
|
||||
// Round-Robin Zuweisung
|
||||
const inserts: {
|
||||
@@ -110,8 +129,8 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
kitaId,
|
||||
childId: { in: childrenIds },
|
||||
date: {
|
||||
gte: new Date(targetYear, targetMonth - 1, 1),
|
||||
lt: new Date(targetYear, targetMonth, 1),
|
||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -126,7 +145,7 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
return { success: true };
|
||||
return { success: true, dates };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Speichern der Notdienste:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { executeAlarmCommand } from "@/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||
|
||||
export type AlarmActionResult = {
|
||||
error?: string;
|
||||
warning?: string;
|
||||
success?: boolean;
|
||||
alarmId?: string;
|
||||
};
|
||||
export async function alarmAction(
|
||||
command: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<AlarmActionResult> {
|
||||
const actor = await requireAlarmActor(command !== "readNotice");
|
||||
try {
|
||||
const result = await executeAlarmCommand(actor, command, data);
|
||||
let warning: string | undefined;
|
||||
if (result.emailIds.length) {
|
||||
try {
|
||||
const delivery = await deliverAlarmEmails(result.emailIds);
|
||||
if (delivery.failed)
|
||||
warning =
|
||||
"Gespeichert. Einige E-Mails warten noch auf den Versand. Bitte den Versandstand im Alarm prüfen.";
|
||||
else if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.RESEND_API_KEY
|
||||
)
|
||||
warning = "Gespeichert. E-Mail-Versand lokal simuliert.";
|
||||
} catch {
|
||||
warning = "Gespeichert. Der E-Mail-Versand ist noch ausstehend.";
|
||||
}
|
||||
}
|
||||
for (const path of [
|
||||
"/dashboard",
|
||||
"/dashboard/notdienst/alarm",
|
||||
"/dashboard/notdienst/plan",
|
||||
"/dashboard/kalender",
|
||||
])
|
||||
revalidatePath(path);
|
||||
revalidatePath("/alert/[token]", "page");
|
||||
return { success: true, alarmId: result.alarmId, warning };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return {
|
||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
};
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes("prisma") &&
|
||||
!error.message.includes("Invalid `")
|
||||
)
|
||||
return { error: error.message };
|
||||
return { error: "Die Alarmaktion konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getAlarmFeed, managerRoles } from "@/lib/alarms/service";
|
||||
import {
|
||||
alarmLabel,
|
||||
durationLabel,
|
||||
dateKey,
|
||||
needsClarification,
|
||||
} from "@/lib/alarms/format";
|
||||
import { AlarmRefresh } from "./forms";
|
||||
|
||||
export async function AlarmDashboardHints() {
|
||||
const { user } = await requireKitaSession();
|
||||
if (![...managerRoles, "ELTERN"].includes(user.role)) return null;
|
||||
let alarms;
|
||||
try {
|
||||
alarms = await getAlarmFeed({
|
||||
id: user.id!,
|
||||
kitaId: user.kitaId,
|
||||
role: user.role,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!alarms.length) return null;
|
||||
return (
|
||||
<section className="mb-6 rounded-2xl border border-[var(--accent-mid)] bg-[var(--surface)] p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">Aktuelle Notdienstalarme</h2>
|
||||
<AlarmRefresh />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{alarms.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/dashboard/notdienst/alarm?alarm=${a.id}`}
|
||||
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm ${needsClarification(a) ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||
>
|
||||
<span>
|
||||
{dateKey(a.date).split("-").reverse().join(".")} · {a.familyName}
|
||||
<span className="mt-1 block text-xs text-[var(--ink-soft)]">
|
||||
{durationLabel(a)}
|
||||
</span>
|
||||
</span>
|
||||
<strong>{alarmLabel(a)} →</strong>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
import { useEffect, useId, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { clockMinute } from "@/lib/staff/calculations";
|
||||
import { alarmAction } from "./actions";
|
||||
|
||||
const control =
|
||||
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
|
||||
export function AlarmRefresh() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) router.refresh();
|
||||
}, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [router]);
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => router.refresh()}>
|
||||
Stand aktualisieren
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export type AlarmCandidate = { id: string; label: string; emails: string[] };
|
||||
export function AlarmCreateForm({
|
||||
candidates,
|
||||
educators,
|
||||
selected,
|
||||
}: {
|
||||
candidates: AlarmCandidate[];
|
||||
educators: { id: string; name: string }[];
|
||||
selected?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!!selected);
|
||||
const [assignmentId, setAssignmentId] = useState(
|
||||
candidates.find((c) => c.id === selected)?.id ?? candidates[0]?.id ?? "",
|
||||
);
|
||||
const [wholeDay, setWholeDay] = useState(true);
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const prefix = useId();
|
||||
const candidate = candidates.find((c) => c.id === assignmentId);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!candidates.length}>
|
||||
<BellRing className="mr-2 h-4 w-4" />
|
||||
Notdienstalarm auslösen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notdienstalarm auslösen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die eingeteilte Familie wird per E-Mail und in der App informiert.
|
||||
Eine Antwort gilt für den Haushalt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
const result = await alarmAction("create", {
|
||||
assignmentId,
|
||||
wholeDay,
|
||||
replyMinutes: Number(form.get("replyMinutes")),
|
||||
...(wholeDay
|
||||
? {}
|
||||
: {
|
||||
startMinute: clockMinute(String(form.get("start"))),
|
||||
endMinute: clockMinute(String(form.get("end"))),
|
||||
}),
|
||||
...(form.get("educatorId")
|
||||
? { educatorId: String(form.get("educatorId")) }
|
||||
: {}),
|
||||
notes: String(form.get("notes") ?? ""),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Alarm ausgelöst.");
|
||||
setOpen(false);
|
||||
router.push(`/dashboard/notdienst/alarm?alarm=${result.alarmId}`);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className="grid gap-4">
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-assignment`}
|
||||
>
|
||||
Eingeteilte Familie
|
||||
<select
|
||||
id={`${prefix}-assignment`}
|
||||
className={control}
|
||||
required
|
||||
value={assignmentId}
|
||||
onChange={(event) => setAssignmentId(event.target.value)}
|
||||
>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="rounded-lg bg-[var(--surface-2)] p-3 text-sm">
|
||||
<p className="font-medium">Empfänger</p>
|
||||
{candidate?.emails.length ? (
|
||||
<ul className="mt-1 grid gap-1 break-all">
|
||||
{candidate.emails.map((email) => (
|
||||
<li key={email}>{email}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[var(--danger)]">
|
||||
Keine Elternkonten hinterlegt. Bitte den Haushalt zuerst
|
||||
vervollständigen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-duration`}
|
||||
>
|
||||
Einsatzdauer
|
||||
<select
|
||||
id={`${prefix}-duration`}
|
||||
className={control}
|
||||
value={wholeDay ? "whole" : "hours"}
|
||||
onChange={(event) =>
|
||||
setWholeDay(event.target.value === "whole")
|
||||
}
|
||||
>
|
||||
<option value="whole">Ganzer Kita-Tag</option>
|
||||
<option value="hours">Bestimmte Uhrzeiten</option>
|
||||
</select>
|
||||
</label>
|
||||
{!wholeDay && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-start`}
|
||||
>
|
||||
Beginn
|
||||
<Input
|
||||
id={`${prefix}-start`}
|
||||
type="time"
|
||||
name="start"
|
||||
required
|
||||
defaultValue="08:00"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-end`}
|
||||
>
|
||||
Ende
|
||||
<Input
|
||||
id={`${prefix}-end`}
|
||||
type="time"
|
||||
name="end"
|
||||
required
|
||||
defaultValue="16:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-minutes`}
|
||||
>
|
||||
Antwortfrist in Minuten
|
||||
<Input
|
||||
id={`${prefix}-minutes`}
|
||||
name="replyMinutes"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
step={1}
|
||||
defaultValue={30}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Die Frist beginnt beim Auslösen und endet spätestens mit dem
|
||||
Einsatz. Eine Absage erfordert sofort Klärung.
|
||||
</p>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-educator`}
|
||||
>
|
||||
Ausgefallene Fachkraft (optional, intern)
|
||||
<select
|
||||
id={`${prefix}-educator`}
|
||||
className={control}
|
||||
name="educatorId"
|
||||
>
|
||||
<option value="">Ohne Zuordnung</option>
|
||||
{educators.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-notes`}>
|
||||
Interne Notiz (optional)
|
||||
<textarea
|
||||
id={`${prefix}-notes`}
|
||||
className={control}
|
||||
name="notes"
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" disabled={!candidate?.emails.length}>
|
||||
{pending
|
||||
? "Alarm wird gespeichert …"
|
||||
: "Alarm auslösen und Familie informieren"}
|
||||
</Button>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function AlarmCommand({
|
||||
alarmId,
|
||||
command,
|
||||
label,
|
||||
}: {
|
||||
alarmId: string;
|
||||
command: "cancel" | "recordResponse" | "approveLate";
|
||||
label: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false),
|
||||
[pending, start] = useTransition(),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter(),
|
||||
prefix = useId();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{command === "cancel"
|
||||
? "Alle angeschriebenen Elternkonten erhalten eine Stornierung. Ihre Rückmeldelinks erlauben danach keine weitere Antwort."
|
||||
: "Die Rückmeldung gilt für den gesamten Haushalt. Die Familie und die Koordination werden über den Stand informiert."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
const result = await alarmAction(command, {
|
||||
alarmId,
|
||||
reason: String(data.get("reason")),
|
||||
...(command === "recordResponse"
|
||||
? { approve: data.get("response") === "yes" }
|
||||
: {}),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Gespeichert.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{command === "recordResponse" && (
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-response`}
|
||||
>
|
||||
Telefonische Rückmeldung / Korrektur
|
||||
<select
|
||||
id={`${prefix}-response`}
|
||||
name="response"
|
||||
className={control}
|
||||
>
|
||||
<option value="yes">Haushalt übernimmt</option>
|
||||
<option value="no">Haushalt kann nicht übernehmen</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-reason`}>
|
||||
Interne Begründung
|
||||
<textarea
|
||||
id={`${prefix}-reason`}
|
||||
className={control}
|
||||
name="reason"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Begründung bleibt intern und wird nicht per E-Mail versendet.
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button disabled={pending} type="submit">
|
||||
{pending
|
||||
? "Wird gespeichert …"
|
||||
: command === "cancel"
|
||||
? "Alarm stornieren"
|
||||
: "Rückmeldung speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function AlarmSimpleAction({
|
||||
command,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
command: "retry" | "readNotice";
|
||||
data: Record<string, unknown>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const result = await alarmAction(command, data);
|
||||
if (result.error) toast.error(result.error);
|
||||
else if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Gespeichert.");
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Bitte warten …" : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import Link from "next/link";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { managerRoles, importLegacyAlarms } from "@/lib/alarms/service";
|
||||
import {
|
||||
alarmLabel,
|
||||
dateKey,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
needsClarification,
|
||||
todayKey,
|
||||
} from "@/lib/alarms/format";
|
||||
import { addDays, dateOnly } from "@/lib/staff/calculations";
|
||||
import {
|
||||
AlarmCreateForm,
|
||||
AlarmCommand,
|
||||
AlarmSimpleAction,
|
||||
AlarmRefresh,
|
||||
} from "./forms";
|
||||
|
||||
export const metadata = { title: "Notdienstalarm · Kita-Planer" };
|
||||
const historyLabels: Record<string, string> = {
|
||||
CONFIRMED: "Übernahme bestätigt",
|
||||
DECLINED: "Absage",
|
||||
LATE_CONFIRMATION: "Verspätete Zusage – Freigabe erforderlich",
|
||||
};
|
||||
export default async function AlarmPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ alarm?: string; assignment?: string }>;
|
||||
}) {
|
||||
const actor = await requireAlarmActor(false),
|
||||
manager = managerRoles.includes(actor.role);
|
||||
const params = await searchParams,
|
||||
now = new Date(),
|
||||
today = todayKey(now);
|
||||
if (manager) await importLegacyAlarms(actor.kitaId);
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: actor.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
const [kita, alarms, assignments, educators, notifications] =
|
||||
await Promise.all([
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: actor.kitaId },
|
||||
select: { name: true, notdienstModuleEnabled: true },
|
||||
}),
|
||||
prisma.notdienstAlarm.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||
...(params.alarm
|
||||
? { id: params.alarm }
|
||||
: { date: { gte: dateOnly(addDays(today, -30)) } }),
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
include: { user: { select: { email: true, firstName: true } } },
|
||||
},
|
||||
emails: { orderBy: { createdAt: "desc" } },
|
||||
history: manager
|
||||
? { orderBy: { createdAt: "desc" }, take: 20 }
|
||||
: false,
|
||||
},
|
||||
orderBy: [{ date: "desc" }, { triggeredAt: "desc" }],
|
||||
take: 100,
|
||||
}),
|
||||
manager
|
||||
? prisma.notdienstAssignment.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
date: { gte: dateOnly(today), lt: dateOnly(addDays(today, 93)) },
|
||||
plan: { status: "PUBLISHED" },
|
||||
alarms: { none: { activeAssignmentId: { not: null } } },
|
||||
},
|
||||
include: {
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: "ELTERN", kitaId: actor.kitaId },
|
||||
select: { email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
manager
|
||||
? prisma.educator.findMany({
|
||||
where: { kitaId: actor.kitaId, active: true },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
orderBy: { lastName: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
prisma.notdienstAlarmNotification.findMany({
|
||||
where: {
|
||||
userId: actor.id,
|
||||
alarm: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||
},
|
||||
readAt: null,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
const candidates = assignments.map((a) => ({
|
||||
id: a.id,
|
||||
label: `${dateKey(a.date).split("-").reverse().join(".")} · ${a.child.family.name}`,
|
||||
emails: a.child.family.users.map((u) => u.email),
|
||||
}));
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||
NOTDIENST · {kita.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl tracking-tight text-[var(--ink)]">
|
||||
Notdienst
|
||||
<span className="italic text-[var(--accent-deep)]">alarm</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{manager
|
||||
? "Familien anfragen, Rückmeldungen verfolgen und offene Einsätze klären."
|
||||
: "Eure Rückmeldung gilt für den gesamten Haushalt."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AlarmRefresh />
|
||||
{manager && kita.notdienstModuleEnabled && (
|
||||
<AlarmCreateForm
|
||||
candidates={candidates}
|
||||
educators={educators.map((e) => ({
|
||||
id: e.id,
|
||||
name: `${e.firstName} ${e.lastName}`,
|
||||
}))}
|
||||
selected={params.assignment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{manager &&
|
||||
(!process.env.NOTDIENST_JOB_SECRET ||
|
||||
process.env.NOTDIENST_JOB_SECRET.length < 32) && (
|
||||
<p className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||
Die automatische Fristprüfung ist noch nicht eingerichtet. Für
|
||||
Erinnerungen bei geschlossener App muss die Hosting-Konfiguration
|
||||
ergänzt werden. Fristabläufe sind hier bereits sichtbar.
|
||||
</p>
|
||||
)}
|
||||
{!kita.notdienstModuleEnabled && (
|
||||
<p className="mb-6 text-sm">
|
||||
Das Notdienstmodul ist deaktiviert; vorhandene Alarme bleiben
|
||||
einsehbar.
|
||||
</p>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<details className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold">
|
||||
{notifications.length} neue Hinweise
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3"
|
||||
>
|
||||
<Link
|
||||
className="max-w-3xl text-sm underline decoration-[var(--accent-mid)] underline-offset-4"
|
||||
href={`/dashboard/notdienst/alarm?alarm=${n.alarmId}`}
|
||||
>
|
||||
{n.message}
|
||||
</Link>
|
||||
<AlarmSimpleAction command="readNotice" data={{ id: n.id }}>
|
||||
Gelesen
|
||||
</AlarmSimpleAction>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{params.alarm && (
|
||||
<Link
|
||||
className="mb-5 inline-block text-sm text-[var(--accent-deep)] underline"
|
||||
href="/dashboard/notdienst/alarm"
|
||||
>
|
||||
Alle Alarme anzeigen
|
||||
</Link>
|
||||
)}
|
||||
{manager && !candidates.length && !params.alarm && (
|
||||
<p className="mb-5 text-sm text-[var(--ink-soft)]">
|
||||
Neue Alarme benötigen eine aktuelle oder zukünftige Einteilung in
|
||||
einem veröffentlichten Plan.{" "}
|
||||
<Link href="/dashboard/notdienst/plan" className="underline">
|
||||
Notdienstplan öffnen
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-5">
|
||||
{alarms.length ? (
|
||||
alarms.map((a) => {
|
||||
const clarify = needsClarification(a, now),
|
||||
ended = a.expiresAt <= now;
|
||||
const pending = a.emails.filter((e) => !e.sentAt && !e.skippedAt);
|
||||
const ownRecipient = a.recipients.find(
|
||||
(r) => r.userId === actor.id,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={a.id}
|
||||
id={`alarm-${a.id}`}
|
||||
className={`overflow-hidden rounded-2xl border bg-[var(--surface)] ${clarify ? "border-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-start justify-between gap-4 border-b border-[var(--hairline-soft)] p-5 sm:p-6 ${clarify ? "bg-[var(--surface-2)]" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--ink-soft)]">
|
||||
<BellRing className="h-4 w-4" />
|
||||
{dateKey(a.date) === today ? "Heute · " : ""}
|
||||
{dateKey(a.date).split("-").reverse().join(".")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl">{a.familyName}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
{durationLabel(a)}
|
||||
{ended ? " · Einsatz beendet" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`max-w-full rounded-full border px-3 py-1.5 text-xs font-semibold ${clarify ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"}`}
|
||||
>
|
||||
{alarmLabel(a, now)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-5 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 text-sm">
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
Antwortfrist
|
||||
</span>
|
||||
<br />
|
||||
<strong>{instantLabel(a.deadline)} Uhr</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">Ausgelöst</span>
|
||||
<br />
|
||||
{instantLabel(a.triggeredAt)} Uhr
|
||||
</p>
|
||||
{a.respondedAt && (
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
Rückmeldung
|
||||
</span>
|
||||
<br />
|
||||
{instantLabel(a.respondedAt)} Uhr ·{" "}
|
||||
{a.responseSource === "PHONE"
|
||||
? "Telefonisch dokumentiert"
|
||||
: a.responseSource === "COORDINATION"
|
||||
? "Freigabe durch Koordination"
|
||||
: a.responseSource === "LEGACY"
|
||||
? "Bisherige Alarmierung"
|
||||
: "Haushaltsantwort"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{manager ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{a.status === "LATE_CONFIRMATION" && !ended && (
|
||||
<AlarmCommand
|
||||
command="approveLate"
|
||||
alarmId={a.id}
|
||||
label="Verspätete Zusage freigeben"
|
||||
/>
|
||||
)}
|
||||
{a.status !== "CANCELLED" && !ended && (
|
||||
<AlarmCommand
|
||||
command="recordResponse"
|
||||
alarmId={a.id}
|
||||
label="Telefonische Rückmeldung / Korrektur"
|
||||
/>
|
||||
)}
|
||||
{a.status !== "CANCELLED" && (
|
||||
<AlarmCommand
|
||||
command="cancel"
|
||||
alarmId={a.id}
|
||||
label="Alarm stornieren"
|
||||
/>
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<AlarmSimpleAction
|
||||
command="retry"
|
||||
data={{ alarmId: a.id }}
|
||||
>
|
||||
Ausstehende E-Mails erneut senden ({pending.length})
|
||||
</AlarmSimpleAction>
|
||||
)}
|
||||
</div>
|
||||
{clarify && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-3 text-sm leading-6">
|
||||
Bitte den Haushalt kontaktieren oder Ersatz manuell
|
||||
organisieren. Weitere Familien werden nicht
|
||||
automatisch alarmiert.
|
||||
</p>
|
||||
)}
|
||||
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Versandstand (
|
||||
{a.emails.filter((e) => e.sentAt).length} angenommen,{" "}
|
||||
{pending.length} ausstehend)
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{a.emails.map((e) => (
|
||||
<p key={e.id} className="break-all">
|
||||
{a.recipients.find((r) => r.userId === e.userId)
|
||||
?.user.email ?? "Koordination / Admin"}{" "}
|
||||
·{" "}
|
||||
{e.kind === "REQUEST"
|
||||
? "Anfrage"
|
||||
: e.kind === "CANCEL"
|
||||
? "Stornierung"
|
||||
: "Hinweis"}
|
||||
:{" "}
|
||||
{e.sentAt
|
||||
? "Vom Maildienst angenommen"
|
||||
: e.skippedAt
|
||||
? "Nicht mehr erforderlich / Empfänger geändert"
|
||||
: e.failed
|
||||
? "Versand fehlgeschlagen – wiederholbar"
|
||||
: "Versand ausstehend"}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Interne Details und Verlauf
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{a.educatorId && (
|
||||
<p>
|
||||
Ausgefallene Fachkraft:{" "}
|
||||
{educators.find((e) => e.id === a.educatorId)
|
||||
?.firstName ??
|
||||
"Ehemalige / inaktive Fachkraft"}{" "}
|
||||
{educators.find((e) => e.id === a.educatorId)
|
||||
?.lastName ?? ""}
|
||||
</p>
|
||||
)}
|
||||
{a.notes && <p>Interne Notiz: {a.notes}</p>}
|
||||
{a.cancelReason && (
|
||||
<p>Stornierungsgrund: {a.cancelReason}</p>
|
||||
)}
|
||||
{a.history.map((h) => (
|
||||
<p key={h.id}>
|
||||
{instantLabel(h.createdAt)} · {h.action}:{" "}
|
||||
{historyLabels[h.details] ?? h.details}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ownRecipient && a.status === "PENDING" && !ended && (
|
||||
<Link
|
||||
className="inline-flex w-fit rounded-lg bg-[var(--accent-deep)] px-4 py-2 text-sm font-semibold text-white"
|
||||
href={`/alert/${ownRecipient.token}`}
|
||||
>
|
||||
Für unseren Haushalt antworten
|
||||
</Link>
|
||||
)}
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
{a.status === "LATE_CONFIRMATION"
|
||||
? "Eure verspätete Zusage wartet auf Freigabe durch die Koordination."
|
||||
: a.status === "CONFIRMED"
|
||||
? "Die Kita rechnet mit euch. Änderungen bitte mit der Koordination klären."
|
||||
: a.status === "CANCELLED"
|
||||
? "Ihr müsst für diesen Alarm nicht einspringen."
|
||||
: a.status === "DECLINED"
|
||||
? "Eure Absage ist gespeichert. Die Koordination organisiert Ersatz."
|
||||
: "Eine Antwort eines Elternteils gilt für euren ganzen Haushalt."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] p-8 text-sm text-[var(--ink-soft)]">
|
||||
{params.alarm
|
||||
? "Kein zugänglicher Alarm gefunden."
|
||||
: "Noch keine Notdienstalarme in diesem Zeitraum."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, CalendarHeart } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotdienstForm } from "./notdienst-form";
|
||||
import {
|
||||
NotdienstForm,
|
||||
type NotdienstCalendarItem,
|
||||
} from "./notdienst-form";
|
||||
|
||||
type NotdienstEntryProps = {
|
||||
hasAnyAvailability: boolean;
|
||||
targetYear: number;
|
||||
targetMonth: number;
|
||||
isLocked: boolean;
|
||||
requiredDaysTotal: number;
|
||||
initialSelectedDates: string[];
|
||||
childrenIds: string[];
|
||||
calendarItems: NotdienstCalendarItem[];
|
||||
};
|
||||
|
||||
export function NotdienstEntry({
|
||||
hasAnyAvailability,
|
||||
targetYear,
|
||||
targetMonth,
|
||||
isLocked,
|
||||
requiredDaysTotal,
|
||||
initialSelectedDates,
|
||||
childrenIds,
|
||||
calendarItems,
|
||||
}: NotdienstEntryProps) {
|
||||
const [showForm, setShowForm] = useState(hasAnyAvailability);
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-gradient-to-b from-emerald-50/70 to-background p-8 text-center">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 shadow-sm">
|
||||
<CalendarHeart className="h-10 w-10" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||
Willkommen beim Notdienst!
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
Es sieht so aus, als hättest du noch keine Termine eingetragen.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-6"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
Jetzt erste Pflicht-Termine eintragen
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NotdienstForm
|
||||
targetYear={targetYear}
|
||||
@@ -61,6 +32,7 @@ export function NotdienstEntry({
|
||||
requiredDaysTotal={requiredDaysTotal}
|
||||
initialSelectedDates={initialSelectedDates}
|
||||
childrenIds={childrenIds}
|
||||
calendarItems={calendarItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Info, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Info,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameMonth,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} 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 { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
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.
|
||||
// =====================================================================
|
||||
export const notdienstEventCategories = {
|
||||
closure: {
|
||||
label: "Schließtag",
|
||||
dot: "oklch(0.62 0.10 35)",
|
||||
bg: "oklch(0.96 0.04 35)",
|
||||
border: "oklch(0.88 0.04 35)",
|
||||
text: "oklch(0.38 0.08 35)",
|
||||
},
|
||||
team: {
|
||||
label: "Teamtag",
|
||||
dot: "oklch(0.62 0.10 70)",
|
||||
bg: "oklch(0.96 0.04 70)",
|
||||
border: "oklch(0.88 0.04 70)",
|
||||
text: "oklch(0.38 0.08 70)",
|
||||
},
|
||||
parents: {
|
||||
label: "Elternabend",
|
||||
dot: "oklch(0.62 0.10 280)",
|
||||
bg: "oklch(0.96 0.04 280)",
|
||||
border: "oklch(0.88 0.04 280)",
|
||||
text: "oklch(0.38 0.08 280)",
|
||||
},
|
||||
social: {
|
||||
label: "Fest",
|
||||
dot: "oklch(0.62 0.10 155)",
|
||||
bg: "oklch(0.96 0.04 155)",
|
||||
border: "oklch(0.88 0.04 155)",
|
||||
text: "oklch(0.38 0.08 155)",
|
||||
},
|
||||
birthday: {
|
||||
label: "Geburtstag",
|
||||
dot: "oklch(0.62 0.10 320)",
|
||||
bg: "oklch(0.96 0.04 320)",
|
||||
border: "oklch(0.88 0.04 320)",
|
||||
text: "oklch(0.38 0.08 320)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type NotdienstEventCategory = keyof typeof notdienstEventCategories;
|
||||
|
||||
export type NotdienstCalendarItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: NotdienstEventCategory;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allDay: boolean;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
interface NotdienstFormProps {
|
||||
targetYear: number;
|
||||
targetMonth: number;
|
||||
isLocked: boolean;
|
||||
requiredDaysTotal: number;
|
||||
initialSelectedDates: string[]; // ISO Strings
|
||||
initialSelectedDates: string[];
|
||||
childrenIds: string[];
|
||||
calendarItems: NotdienstCalendarItem[];
|
||||
}
|
||||
|
||||
export function NotdienstForm({
|
||||
@@ -34,33 +96,44 @@ export function NotdienstForm({
|
||||
requiredDaysTotal,
|
||||
initialSelectedDates,
|
||||
childrenIds,
|
||||
calendarItems,
|
||||
}: NotdienstFormProps) {
|
||||
// Lokaler State für die Auswahl. Ein Datum liegt immer als ISO-String vor (z.B. "2026-06-01T00:00:00.000Z")
|
||||
const router = useRouter();
|
||||
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
|
||||
const [selectedDates, setSelectedDates] = useState<Set<string>>(
|
||||
new Set(initialSelectedDates),
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
const closureDateKeys = useMemo(
|
||||
() => getClosureDateKeys(calendarItems),
|
||||
[calendarItems],
|
||||
);
|
||||
const selectableDateKeys = useMemo(() => {
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
return new Set(
|
||||
workingDays
|
||||
.map(formatDateKey)
|
||||
.filter((dateKey) => !closureDateKeys.has(dateKey)),
|
||||
);
|
||||
}, [closureDateKeys, targetMonth, targetYear]);
|
||||
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
|
||||
const selectedCount = selectedDates.size;
|
||||
const isFulfilled = selectedCount >= requiredDaysTotal;
|
||||
const hasMinimumRequirement = requiredDaysTotal > 0;
|
||||
const isFulfilled =
|
||||
!hasMinimumRequirement || selectedCount >= requiredDaysTotal;
|
||||
const selectedLabel =
|
||||
selectedCount === 1 ? "1 Tag markiert" : `${selectedCount} Tage markiert`;
|
||||
|
||||
function toggleDate(dateStr: string) {
|
||||
function handleChange(nextDates: Set<string>) {
|
||||
if (isLocked || isPending) return;
|
||||
|
||||
const newSet = new Set(selectedDates);
|
||||
if (newSet.has(dateStr)) {
|
||||
newSet.delete(dateStr);
|
||||
} else {
|
||||
newSet.add(dateStr);
|
||||
}
|
||||
setSelectedDates(newSet);
|
||||
setSelectedDates(nextDates);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (isLocked) return;
|
||||
|
||||
if (!isFulfilled) {
|
||||
if (hasMinimumRequirement && !isFulfilled) {
|
||||
toast.error(
|
||||
`Bitte wähle noch ${requiredDaysTotal - selectedCount} weitere Termine aus.`,
|
||||
);
|
||||
@@ -78,107 +151,489 @@ export function NotdienstForm({
|
||||
if (result?.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
setSelectedDates(new Set(result.dates));
|
||||
toast.success("Verfügbarkeiten erfolgreich gespeichert.");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
{isLocked ? (
|
||||
<div className="mb-4 flex items-start gap-3 rounded-[10px] border border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)] p-4 text-sm text-[oklch(0.38_0.08_35)]">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
Die Frist für diesen Monat ist abgelaufen. Änderungen sind nicht
|
||||
mehr möglich.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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 className="overflow-x-auto">
|
||||
<NotdienstCalendar
|
||||
month={monthStart}
|
||||
events={calendarItems}
|
||||
selected={selectedDates}
|
||||
requiredDays={requiredDaysTotal}
|
||||
disabled={isLocked || isPending}
|
||||
selectableDateKeys={selectableDateKeys}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
isSaving={isPending}
|
||||
/>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{hasMinimumRequirement
|
||||
? `Du hast ${selectedCount} von ${requiredDaysTotal} benötigten Tagen markiert.`
|
||||
: `${selectedLabel}. Für diese Kita ist aktuell keine Mindestanzahl hinterlegt.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NotdienstCalendarProps = {
|
||||
month: Date;
|
||||
events: NotdienstCalendarItem[];
|
||||
selected: Set<string>;
|
||||
requiredDays: number;
|
||||
disabled?: boolean;
|
||||
selectableDateKeys: Set<string>;
|
||||
isSaving?: boolean;
|
||||
onChange: (selected: Set<string>) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function NotdienstCalendar({
|
||||
month,
|
||||
events,
|
||||
selected,
|
||||
requiredDays,
|
||||
disabled = false,
|
||||
selectableDateKeys,
|
||||
isSaving = false,
|
||||
onChange,
|
||||
onSave,
|
||||
}: NotdienstCalendarProps) {
|
||||
const [visibleMonth, setVisibleMonth] = useState(month);
|
||||
const [activeEvent, setActiveEvent] = useState<NotdienstCalendarItem | null>(
|
||||
null,
|
||||
);
|
||||
const visibleMonthStart = startOfMonth(visibleMonth);
|
||||
const visibleMonthEnd = endOfMonth(visibleMonthStart);
|
||||
const calendarStart = startOfWeek(visibleMonthStart, { weekStartsOn: 1 });
|
||||
const calendarEnd = endOfWeek(visibleMonthEnd, { weekStartsOn: 1 });
|
||||
const calendarDays = eachDayOfInterval({
|
||||
start: calendarStart,
|
||||
end: calendarEnd,
|
||||
}).map((day) => startOfDay(day));
|
||||
const selectedCount = selected.size;
|
||||
const extraCount = Math.max(0, selectedCount - requiredDays);
|
||||
const statusText =
|
||||
requiredDays > 0 && selectedCount >= requiredDays
|
||||
? `Fertig — alle ${requiredDays} Tage markiert`
|
||||
: `${selectedCount} von ${requiredDays} Tagen`;
|
||||
|
||||
function toggleDate(dateKey: string) {
|
||||
if (disabled || !selectableDateKeys.has(dateKey)) return;
|
||||
|
||||
const nextSelected = new Set(selected);
|
||||
if (nextSelected.has(dateKey)) {
|
||||
nextSelected.delete(dateKey);
|
||||
} else {
|
||||
nextSelected.add(dateKey);
|
||||
}
|
||||
onChange(nextSelected);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="min-w-[1040px] bg-[var(--notdienst-bg)] px-11 py-10 text-[var(--notdienst-ink)] shadow-sm [--notdienst-accent-deep:oklch(0.42_0.10_38)] [--notdienst-accent-mid:oklch(0.82_0.07_38)] [--notdienst-accent-soft:oklch(0.93_0.04_38)] [--notdienst-accent:oklch(0.58_0.11_38)] [--notdienst-bg:oklch(0.965_0.012_75)] [--notdienst-hairline-soft:oklch(0.93_0.01_75)] [--notdienst-hairline:oklch(0.90_0.012_75)] [--notdienst-ink-faint:oklch(0.76_0.01_45)] [--notdienst-ink-muted:oklch(0.60_0.012_45)] [--notdienst-ink-soft:oklch(0.45_0.015_45)] [--notdienst-ink:oklch(0.26_0.015_45)] [--notdienst-out-month:oklch(0.82_0.012_75)] [--notdienst-surface-week:oklch(0.945_0.014_75)] [--notdienst-surface:oklch(0.985_0.008_75)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(oklch(0.85 0.02 75 / 0.10) 1px, transparent 1px)",
|
||||
backgroundSize: "16px 16px",
|
||||
}}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-8">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase leading-none text-[var(--notdienst-accent)] [letter-spacing:0.18em]">
|
||||
NOTDIENST · {format(visibleMonthStart, "MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight text-[var(--notdienst-ink)] [letter-spacing:-0.025em]">
|
||||
Wann magst du{" "}
|
||||
<em className="font-normal italic text-[var(--notdienst-accent-deep)]">
|
||||
helfen
|
||||
</em>
|
||||
?
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] px-5 py-3 shadow-[0_12px_30px_oklch(0.36_0.02_45_/_0.06)]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: Math.max(requiredDays, 1) }).map(
|
||||
(_, index) => {
|
||||
const isMarked = index < selectedCount;
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full border border-[var(--notdienst-accent)]",
|
||||
isMarked && "bg-[var(--notdienst-accent)]",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{extraCount > 0 ? (
|
||||
<span className="ml-1 text-[11px] font-semibold text-[var(--notdienst-accent-deep)]">
|
||||
+{extraCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--notdienst-ink-soft)]">
|
||||
{statusText}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={disabled || isSaving || selectedCount < requiredDays}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-full bg-[var(--notdienst-accent-deep)] px-5 text-[13px] font-bold text-white transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-9 flex items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-[22px] font-medium text-[var(--notdienst-ink)] [font-variant-numeric:tabular-nums]">
|
||||
{format(visibleMonthStart, "MMMM yyyy", { locale: de })}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorheriger Monat"
|
||||
onClick={() =>
|
||||
setVisibleMonth(
|
||||
new Date(
|
||||
visibleMonthStart.getFullYear(),
|
||||
visibleMonthStart.getMonth() - 1,
|
||||
1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächster Monat"
|
||||
onClick={() =>
|
||||
setVisibleMonth(
|
||||
new Date(
|
||||
visibleMonthStart.getFullYear(),
|
||||
visibleMonthStart.getMonth() + 1,
|
||||
1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] text-[var(--notdienst-ink-soft)] transition hover:border-[var(--notdienst-accent-mid)] hover:text-[var(--notdienst-accent-deep)]"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</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>
|
||||
<CalendarLegend />
|
||||
</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);
|
||||
<div className="mt-8 grid grid-cols-7 gap-1.5">
|
||||
{[
|
||||
"Montag",
|
||||
"Dienstag",
|
||||
"Mittwoch",
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
"Samstag",
|
||||
"Sonntag",
|
||||
].map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className="px-2 text-[11px] font-semibold uppercase text-[var(--notdienst-ink-muted)] [letter-spacing:0.08em]"
|
||||
>
|
||||
{weekday}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
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 className="mt-2 grid grid-cols-7 gap-1.5">
|
||||
{calendarDays.map((day) => {
|
||||
const dateKey = formatDateKey(day);
|
||||
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
|
||||
const isSelected = selected.has(dateKey);
|
||||
const dayEvents = getCalendarItemsForDay(events, day);
|
||||
const hasClosure = dayEvents.some(
|
||||
(event) => event.category === "closure",
|
||||
);
|
||||
const isSelectable =
|
||||
isInTargetMonth && selectableDateKeys.has(dateKey) && !hasClosure;
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
||||
|
||||
return (
|
||||
<CalendarTile
|
||||
key={dateKey}
|
||||
day={day}
|
||||
events={dayEvents}
|
||||
isInTargetMonth={isInTargetMonth}
|
||||
isSelected={isSelected}
|
||||
isSelectable={isSelectable}
|
||||
isWeekend={isWeekend}
|
||||
hasClosure={hasClosure}
|
||||
disabled={disabled}
|
||||
onToggle={() => toggleDate(dateKey)}
|
||||
onOpenEvent={setActiveEvent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeEvent ? (
|
||||
<EventDialog
|
||||
event={activeEvent}
|
||||
onClose={() => setActiveEvent(null)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarTile({
|
||||
day,
|
||||
events,
|
||||
isInTargetMonth,
|
||||
isSelected,
|
||||
isSelectable,
|
||||
isWeekend,
|
||||
hasClosure,
|
||||
disabled,
|
||||
onToggle,
|
||||
onOpenEvent,
|
||||
}: {
|
||||
day: Date;
|
||||
events: NotdienstCalendarItem[];
|
||||
isInTargetMonth: boolean;
|
||||
isSelected: boolean;
|
||||
isSelectable: boolean;
|
||||
isWeekend: boolean;
|
||||
hasClosure: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
onOpenEvent: (event: NotdienstCalendarItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-24 rounded-[10px] border p-[10px_11px] text-left transition",
|
||||
"border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)]",
|
||||
isSelectable &&
|
||||
!disabled &&
|
||||
"cursor-pointer hover:border-[var(--notdienst-accent-mid)] hover:shadow-[0_10px_28px_oklch(0.36_0.02_45_/_0.08)]",
|
||||
isSelected &&
|
||||
"border-[var(--notdienst-accent-mid)] bg-[var(--notdienst-accent-soft)]",
|
||||
hasClosure &&
|
||||
"cursor-default border-[oklch(0.88_0.04_35)] bg-[oklch(0.96_0.018_35)]",
|
||||
(isWeekend || !isInTargetMonth) &&
|
||||
"cursor-default border-[var(--notdienst-hairline-soft)] bg-[var(--notdienst-surface-week)]",
|
||||
)}
|
||||
onClick={isSelectable && !disabled ? onToggle : undefined}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium leading-none [font-variant-numeric:tabular-nums] [letter-spacing:-0.02em]",
|
||||
isWeekend || !isInTargetMonth
|
||||
? "text-[13px] text-[var(--notdienst-out-month)]"
|
||||
: "text-[17px] text-[var(--notdienst-ink)]",
|
||||
isSelected && "text-[var(--notdienst-accent-deep)]",
|
||||
hasClosure && "text-[oklch(0.38_0.08_35)]",
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<Check className="h-2.5 w-2.5 text-[var(--notdienst-accent-deep)] [stroke-width:2.5]" />
|
||||
) : null}
|
||||
{hasClosure ? (
|
||||
<LockKeyhole className="h-3 w-3 text-[oklch(0.38_0.08_35)]" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-[11px] bottom-[10px] space-y-1.5">
|
||||
{events.slice(0, 2).map((event) => (
|
||||
<button
|
||||
key={`${formatDateKey(day)}-${event.id}`}
|
||||
type="button"
|
||||
className="group/event relative flex w-full items-center gap-1.5 overflow-visible text-left"
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
onOpenEvent(event);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-4 w-[3px] shrink-0 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: notdienstEventCategories[event.category].dot,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
|
||||
{event.title}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-2 hidden min-w-44 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-white shadow-xl group-hover/event:block">
|
||||
<span className="block text-[10px] font-semibold uppercase text-white/65 [letter-spacing:0.08em]">
|
||||
{notdienstEventCategories[event.category].label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] font-semibold">
|
||||
{event.title}
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] text-white/75">
|
||||
{formatEventTime(event)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{events.length > 2 ? (
|
||||
<p className="text-[11px] font-medium text-[var(--notdienst-ink-muted)]">
|
||||
+{events.length - 2} weitere
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarLegend() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-4 text-[11px] font-medium text-[var(--notdienst-ink-soft)]">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-[9px] w-[9px] rounded-[2px] bg-[var(--notdienst-accent)]" />
|
||||
Dein Notdienst
|
||||
</span>
|
||||
{Object.entries(notdienstEventCategories).map(([key, category]) => (
|
||||
<span key={key} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-[9px] w-[9px] rounded-[2px]"
|
||||
style={{ backgroundColor: category.dot }}
|
||||
/>
|
||||
{category.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDialog({
|
||||
event,
|
||||
onClose,
|
||||
}: {
|
||||
event: NotdienstCalendarItem;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const category = notdienstEventCategories[event.category];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[oklch(0.25_0.015_45_/_0.20)] p-6 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-[var(--notdienst-hairline)] bg-[var(--notdienst-surface)] p-6 text-[var(--notdienst-ink)] shadow-2xl"
|
||||
onClick={(eventClick) => eventClick.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span
|
||||
className="inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: category.bg,
|
||||
borderColor: category.border,
|
||||
color: category.text,
|
||||
}}
|
||||
>
|
||||
{category.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Detail schließen"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-[var(--notdienst-ink-muted)] transition hover:bg-[var(--notdienst-surface-week)] hover:text-[var(--notdienst-ink)]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="mt-4 text-2xl font-medium leading-tight [letter-spacing:-0.02em]">
|
||||
{event.title}
|
||||
</h3>
|
||||
<p className="mt-3 flex items-center gap-2 text-sm font-medium text-[var(--notdienst-ink-soft)]">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
{formatEventDate(event)}
|
||||
</p>
|
||||
{event.note ? (
|
||||
<p className="mt-5 text-sm leading-6 text-[var(--notdienst-ink-soft)]">
|
||||
{event.note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarItemsForDay(
|
||||
items: NotdienstCalendarItem[],
|
||||
day: Date,
|
||||
) {
|
||||
const dayStart = startOfDay(day).getTime();
|
||||
|
||||
return items.filter((item) => {
|
||||
const itemStart = startOfDay(item.startDate).getTime();
|
||||
const itemEnd = startOfDay(item.endDate).getTime();
|
||||
|
||||
return itemStart <= dayStart && itemEnd >= dayStart;
|
||||
});
|
||||
}
|
||||
|
||||
function getClosureDateKeys(items: NotdienstCalendarItem[]) {
|
||||
const closureKeys = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.category !== "closure") continue;
|
||||
|
||||
const days = eachDayOfInterval({
|
||||
start: startOfDay(item.startDate),
|
||||
end: startOfDay(item.endDate),
|
||||
});
|
||||
|
||||
for (const day of days) {
|
||||
closureKeys.add(formatDateKey(day));
|
||||
}
|
||||
}
|
||||
|
||||
return closureKeys;
|
||||
}
|
||||
|
||||
function formatEventDate(event: NotdienstCalendarItem) {
|
||||
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
|
||||
return event.allDay
|
||||
? `${date} · Ganztägig`
|
||||
: `${date} · ${formatEventTime(event)}`;
|
||||
}
|
||||
|
||||
function formatEventTime(event: NotdienstCalendarItem) {
|
||||
if (event.allDay) return "Ganztägig";
|
||||
|
||||
return `${format(event.startDate, "HH:mm")} - ${format(
|
||||
event.endDate,
|
||||
"HH:mm",
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { Info, Calendar, ArrowRight, ShieldAlert } from "lucide-react";
|
||||
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
|
||||
import { ArrowRight, ShieldAlert } from "lucide-react";
|
||||
import { Manrope } from "next/font/google";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotdienstEntry } from "./notdienst-entry";
|
||||
|
||||
export const metadata = { title: "Notdienst · Kita-Planer" };
|
||||
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export default async function NotdienstPage() {
|
||||
// ELTERN können eintragen; Admins/Koordinatoren haben hier eigentlich
|
||||
// andere Ansichten (Plan-Generierung), aber wir lassen sie zur Demonstration
|
||||
@@ -44,13 +50,15 @@ export default async function NotdienstPage() {
|
||||
: [];
|
||||
|
||||
const targetData = getTargetMonthData();
|
||||
const { targetYear, targetMonth, monthName, isLocked } = targetData;
|
||||
const { targetYear, targetMonth, isLocked } = targetData;
|
||||
const targetMonthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
|
||||
const targetMonthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
|
||||
|
||||
const requiredDaysTotal = children.length * kita.notdienstMinPerChildPerMonth;
|
||||
const childIds = children.map((child) => child.id);
|
||||
|
||||
// Bisherige Einträge für den Zielmonat laden, haushaltsweit über die Kinder.
|
||||
const [availabilities, availabilityCountForCurrentOrNextMonth] =
|
||||
const [availabilities, termine] =
|
||||
children.length > 0
|
||||
? await Promise.all([
|
||||
prisma.notdienstAvailability.findMany({
|
||||
@@ -58,80 +66,94 @@ export default async function NotdienstPage() {
|
||||
kitaId,
|
||||
childId: { in: childIds },
|
||||
date: {
|
||||
gte: new Date(targetYear, targetMonth - 1, 1),
|
||||
lt: new Date(targetYear, targetMonth, 1),
|
||||
gte: targetMonthStart,
|
||||
lt: targetMonthEnd,
|
||||
},
|
||||
},
|
||||
select: { date: true },
|
||||
}),
|
||||
prisma.notdienstAvailability.count({
|
||||
prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
childId: { in: childIds },
|
||||
date: {
|
||||
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||
lt: new Date(new Date().getFullYear(), new Date().getMonth() + 2, 1),
|
||||
},
|
||||
status: TerminStatus.CONFIRMED,
|
||||
startDate: { lt: targetMonthEnd },
|
||||
endDate: { gte: targetMonthStart },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
])
|
||||
: [[], 0];
|
||||
: [[], []];
|
||||
|
||||
const selectedDates = availabilities.map((a) => a.date.toISOString());
|
||||
const selectedDates = availabilities.map((a) => formatDateKey(a.date));
|
||||
|
||||
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>
|
||||
|
||||
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
|
||||
{children.length === 0 ? (
|
||||
<NotdienstLockout />
|
||||
) : (
|
||||
<NotdienstEntry
|
||||
hasAnyAvailability={availabilityCountForCurrentOrNextMonth > 0}
|
||||
targetYear={targetYear}
|
||||
targetMonth={targetMonth}
|
||||
isLocked={isLocked}
|
||||
requiredDaysTotal={requiredDaysTotal}
|
||||
initialSelectedDates={selectedDates}
|
||||
childrenIds={childIds}
|
||||
calendarItems={termine.map((termin) => ({
|
||||
id: termin.id,
|
||||
title: termin.title,
|
||||
category: getTerminCategory(termin.type),
|
||||
startDate: termin.startDate,
|
||||
endDate: termin.endDate,
|
||||
allDay: termin.allDay,
|
||||
note: termin.description,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTerminCategory(type: TerminType) {
|
||||
switch (type) {
|
||||
case TerminType.SCHLIESSTAG:
|
||||
return "closure" as const;
|
||||
case TerminType.TEAMTAG:
|
||||
return "team" as const;
|
||||
case TerminType.KITA_FEST:
|
||||
case TerminType.MITMACH_TAG:
|
||||
return "social" as const;
|
||||
case TerminType.ELTERNABEND:
|
||||
case TerminType.MITGLIEDERVERSAMMLUNG:
|
||||
case TerminType.ELTERNCAFE:
|
||||
return "parents" as const;
|
||||
case TerminType.GEBURTSTAG_INTERN:
|
||||
case TerminType.GEBURTSTAG_EXTERN:
|
||||
return "birthday" as const;
|
||||
default:
|
||||
return "social" as const;
|
||||
}
|
||||
}
|
||||
|
||||
function NotdienstLockout() {
|
||||
return (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed bg-muted/20 p-8 text-center">
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm">
|
||||
<ShieldAlert className="h-10 w-10" />
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<ShieldAlert className="h-10 w-10 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||
<h2 className="mt-6 text-2xl font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Erst Kinder hinterlegen
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Bitte lege zuerst deine Kinder im Profil an, bevor du Notdienste
|
||||
übernimmst.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, type FormEvent } from "react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { updateKitaNotdienstSettingsAction } from "../actions";
|
||||
|
||||
interface SettingsModalProps {
|
||||
initialMinPerChild: number;
|
||||
initialReminderDays: number;
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
initialMinPerChild,
|
||||
initialReminderDays,
|
||||
}: SettingsModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [minPerChild, setMinPerChild] = useState(initialMinPerChild);
|
||||
const [reminderDays, setReminderDays] = useState(initialReminderDays);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (minPerChild < 0 || reminderDays < 0) {
|
||||
toast.error("Werte dürfen nicht negativ sein.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await updateKitaNotdienstSettingsAction(
|
||||
minPerChild,
|
||||
reminderDays
|
||||
);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success("Regeln erfolgreich gespeichert!");
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<SlidersHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Einstellungen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notdienst-Einstellungen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Passe die standardmäßigen Regeln für die Notdienst-Verteilung in deiner Kita an.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="minPerChild">
|
||||
Mindesteinteilungen pro Kind/Monat
|
||||
</Label>
|
||||
<Input
|
||||
id="minPerChild"
|
||||
name="minPerChild"
|
||||
type="number"
|
||||
min="0"
|
||||
required
|
||||
value={minPerChild}
|
||||
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reminderDays">
|
||||
Erinnerungsfrist (Tage vor Monatsende)
|
||||
</Label>
|
||||
<Input
|
||||
id="reminderDays"
|
||||
name="reminderDays"
|
||||
type="number"
|
||||
min="0"
|
||||
required
|
||||
value={reminderDays}
|
||||
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMinPerChild(initialMinPerChild);
|
||||
setReminderDays(initialReminderDays);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Speichern..." : "Regeln speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import {
|
||||
validateAlarmActor,
|
||||
assertAssignmentNotAlarmed,
|
||||
} from "@/lib/alarms/service";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/notdienst/plan · Server Actions
|
||||
// =====================================================================
|
||||
|
||||
export async function generatePlanAction() {
|
||||
export async function generatePlanAction(year?: number, month?: number) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
const { targetYear, targetMonth } = getTargetMonthData();
|
||||
let targetYear: number;
|
||||
let targetMonth: number;
|
||||
if (year !== undefined && month !== undefined) {
|
||||
targetYear = year;
|
||||
targetMonth = month;
|
||||
} else {
|
||||
const data = getTargetMonthData();
|
||||
targetYear = data.targetYear;
|
||||
targetMonth = data.targetMonth;
|
||||
}
|
||||
|
||||
// Prüfen, ob schon ein Plan existiert
|
||||
const existingPlan = await prisma.notdienstPlan.findUnique({
|
||||
@@ -33,8 +53,8 @@ export async function generatePlanAction() {
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: new Date(targetYear, targetMonth - 1, 1),
|
||||
lt: new Date(targetYear, targetMonth, 1),
|
||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||
},
|
||||
},
|
||||
include: { child: { select: { familyId: true } } },
|
||||
@@ -138,54 +158,236 @@ export async function updateAssignmentAction(
|
||||
date: string,
|
||||
newChildId: string | null,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Prüfen, ob Plan noch DRAFT ist
|
||||
const 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) {
|
||||
const child = await tx.child.findFirst({
|
||||
where: {
|
||||
id: newChildId,
|
||||
kitaId,
|
||||
active: true,
|
||||
},
|
||||
select: { id: true },
|
||||
const notifications = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await validateAlarmActor(tx, actor);
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
}
|
||||
|
||||
await tx.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId,
|
||||
planId,
|
||||
childId: newChildId,
|
||||
date: parsedDate,
|
||||
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||
|
||||
if (!isDraft && !isPublished) {
|
||||
throw new Error("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
const today = new Date();
|
||||
const todayUtc = new Date(
|
||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
);
|
||||
if (parsedDate < todayUtc) {
|
||||
throw new Error(
|
||||
"Vergangene Notdienste können nicht geändert werden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { planId, date: parsedDate },
|
||||
include: {
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let newChild: {
|
||||
id: string;
|
||||
family: {
|
||||
users: { email: string; firstName: string; lastName: string }[];
|
||||
};
|
||||
} | null = null;
|
||||
|
||||
if (newChildId) {
|
||||
newChild = await tx.child.findFirst({
|
||||
where: {
|
||||
id: newChildId,
|
||||
kitaId,
|
||||
active: true,
|
||||
},
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!newChild) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAssignment?.childId === newChildId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (existingAssignment)
|
||||
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
|
||||
|
||||
if (!newChildId) {
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstAssignment.delete({
|
||||
where: { id: existingAssignment.id },
|
||||
});
|
||||
}
|
||||
} else if (existingAssignment) {
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: existingAssignment.id },
|
||||
data: { childId: newChildId },
|
||||
});
|
||||
} else {
|
||||
await tx.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId,
|
||||
planId,
|
||||
childId: newChildId,
|
||||
date: parsedDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
status: "PENDING",
|
||||
OR: [
|
||||
{ targetAssignmentId: existingAssignment.id },
|
||||
{ offeredAssignmentId: existingAssignment.id },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublished) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateLabel = format(
|
||||
new Date(yr, mo - 1, dy),
|
||||
"EEEE, dd. MMMM yyyy",
|
||||
{ locale: de },
|
||||
);
|
||||
const messages: {
|
||||
parentEmails: string[];
|
||||
parentNames: string[];
|
||||
subject: string;
|
||||
heading: string;
|
||||
preview: string;
|
||||
body: string;
|
||||
dateLabel: string;
|
||||
}[] = [];
|
||||
|
||||
if (existingAssignment) {
|
||||
const oldParents = existingAssignment.child.family.users.filter(
|
||||
(u) => !!u.email,
|
||||
);
|
||||
if (oldParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: oldParents.map((p) => p.email),
|
||||
parentNames: oldParents.map(
|
||||
(p) => `${p.firstName} ${p.lastName}`,
|
||||
),
|
||||
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
|
||||
heading: "Notdienst wurde geändert",
|
||||
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
|
||||
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
|
||||
dateLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newChild && existingAssignment?.childId !== newChild.id) {
|
||||
const newParents = newChild.family.users.filter((u) => !!u.email);
|
||||
if (newParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: newParents.map((p) => p.email),
|
||||
parentNames: newParents.map(
|
||||
(p) => `${p.firstName} ${p.lastName}`,
|
||||
),
|
||||
subject: `Notdienst-Einteilung am ${dateLabel}`,
|
||||
heading: "Neuer Notdienst-Termin zugewiesen",
|
||||
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
|
||||
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
|
||||
dateLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
},
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
// E-Mails außerhalb der DB-Transaktion senden
|
||||
if (notifications.length > 0) {
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: kitaId },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
if (kita) {
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
for (const notification of notifications) {
|
||||
for (let i = 0; i < notification.parentEmails.length; i++) {
|
||||
const email = notification.parentEmails[i];
|
||||
const name = notification.parentNames[i];
|
||||
|
||||
await sendAppEmail({
|
||||
to: email,
|
||||
subject: notification.subject,
|
||||
react: createElement(NotdienstAssignmentChangeEmail, {
|
||||
parentName: name,
|
||||
heading: notification.heading,
|
||||
preview: notification.preview,
|
||||
body: notification.body,
|
||||
dateLabel: notification.dateLabel,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -193,3 +395,191 @@ export async function updateAssignmentAction(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateKitaNotdienstSettingsAction(
|
||||
minPerChild: number,
|
||||
reminderDays: number,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.kita.update({
|
||||
where: { id: kitaId },
|
||||
data: {
|
||||
notdienstMinPerChildPerMonth: minPerChild,
|
||||
notdienstReminderDaysBefore: reminderDays,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: "Fehler beim Speichern der Einstellungen." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNotdienstRemindersAction(
|
||||
year: number,
|
||||
month: number,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { error: mailConfigError };
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get Kita Name
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: kitaId },
|
||||
select: { name: true },
|
||||
});
|
||||
if (!kita) {
|
||||
return { error: "Kita nicht gefunden." };
|
||||
}
|
||||
|
||||
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
|
||||
const families = await prisma.family.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
children: { some: { active: true } },
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 3. For target month, find availability entries
|
||||
const monthStart = new Date(year, month - 1, 1);
|
||||
const monthEnd = new Date(year, month, 1);
|
||||
|
||||
const respondedFamilies = await prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const respondedFamilyIds = new Set(
|
||||
respondedFamilies.map((a) => a.child.familyId),
|
||||
);
|
||||
|
||||
// 4. Find open families (not responded yet)
|
||||
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
|
||||
|
||||
if (openFamilies.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
// 5. Build emails list and send
|
||||
const monthDate = new Date(year, month - 1, 1);
|
||||
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
let sentCount = 0;
|
||||
for (const family of openFamilies) {
|
||||
for (const parent of family.users) {
|
||||
if (!parent.email) continue;
|
||||
|
||||
const result = await sendAppEmail({
|
||||
to: parent.email,
|
||||
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
|
||||
react: createElement(NotdienstAvailabilityReminderEmail, {
|
||||
parentName: `${parent.firstName} ${parent.lastName}`,
|
||||
kitaName: kita.name,
|
||||
monthLabel,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
sentCount++;
|
||||
} else {
|
||||
console.error(
|
||||
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, count: sentCount };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Versenden der Erinnerungen:", error);
|
||||
return { error: "Fehler beim Versenden der Erinnerungen." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPlanAction(planId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Ensure the plan belongs to this kita and is draft
|
||||
const plan = await tx.notdienstPlan.findFirst({
|
||||
where: { id: planId, kitaId },
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
}
|
||||
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
||||
throw new Error(
|
||||
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all assignments
|
||||
await tx.notdienstAssignment.deleteMany({
|
||||
where: { planId },
|
||||
});
|
||||
|
||||
// Delete the plan itself
|
||||
await tx.notdienstPlan.delete({
|
||||
where: { id: planId },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Fehler beim Zurücksetzen des Plans.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,118 @@
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { addMonths, getYear, getMonth, format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { PlanView } from "./plan-view";
|
||||
import { SettingsModal } from "./_components/settings-modal";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { todayKey } from "@/lib/alarms/format";
|
||||
|
||||
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!;
|
||||
export default async function PlanungsZentralePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ year?: string; month?: string }>;
|
||||
}) {
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
const { targetYear, targetMonth, monthName } = getTargetMonthData();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const now = new Date();
|
||||
const nextMonth = addMonths(now, 1);
|
||||
const targetYear = resolvedSearchParams.year
|
||||
? parseInt(resolvedSearchParams.year, 10)
|
||||
: getYear(nextMonth);
|
||||
const targetMonth = resolvedSearchParams.month
|
||||
? parseInt(resolvedSearchParams.month, 10)
|
||||
: getMonth(nextMonth) + 1;
|
||||
|
||||
// Aktuellen Plan suchen
|
||||
const plan = await prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
assignments: {
|
||||
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthName = format(monthDate, "MMMM", { locale: de });
|
||||
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthEnd = new Date(targetYear, targetMonth, 1);
|
||||
|
||||
const [plan, families, availabilities, closureCount, kita] =
|
||||
await Promise.all([
|
||||
prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
alarms: {
|
||||
where: { activeAssignmentId: { not: null } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Alle Werktage
|
||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
children: { some: { active: true } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
children: {
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.termin.count({
|
||||
where: {
|
||||
kitaId,
|
||||
type: TerminType.SCHLIESSTAG,
|
||||
startDate: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: {
|
||||
notdienstMinPerChildPerMonth: true,
|
||||
notdienstReminderDaysBefore: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||
@@ -44,6 +123,7 @@ export default async function PlanungsZentralePage() {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
familyId: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -68,45 +148,125 @@ export default async function PlanungsZentralePage() {
|
||||
id: c.id,
|
||||
name: `${c.firstName} ${c.lastName}`,
|
||||
parentName,
|
||||
familyId: c.familyId,
|
||||
};
|
||||
});
|
||||
|
||||
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
||||
availabilities.forEach((availability) => {
|
||||
const familyId = availability.child.familyId;
|
||||
const dates = availabilityDatesByFamily.get(familyId) ?? new Set<string>();
|
||||
dates.add(availability.date.toISOString());
|
||||
availabilityDatesByFamily.set(familyId, dates);
|
||||
});
|
||||
|
||||
const familyStatuses = families.map((family) => {
|
||||
const dates = Array.from(availabilityDatesByFamily.get(family.id) ?? []);
|
||||
return {
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
selectedDays: dates.map((date) => ({
|
||||
iso: date,
|
||||
label: new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}).format(new Date(date)),
|
||||
})),
|
||||
childCount: family.children.length,
|
||||
};
|
||||
});
|
||||
|
||||
const respondedFamilies = familyStatuses.filter(
|
||||
(family) => family.selectedDays.length > 0,
|
||||
).length;
|
||||
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
|
||||
const totalAvailabilitySlots = availabilities.length;
|
||||
const requiredDays = workingDays.length;
|
||||
const coverageFactor =
|
||||
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
|
||||
|
||||
// Wir mappen die Assignments auf die Werktage
|
||||
const daysWithAssignments = workingDays.map((day) => {
|
||||
const assignment = plan?.assignments.find(
|
||||
(a) => a.date.getTime() === day.getTime()
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
const isOriginallyUnassigned =
|
||||
!assignment ||
|
||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||
|
||||
return {
|
||||
date: day.toISOString(),
|
||||
assignmentId: assignment?.id || null,
|
||||
childId: assignment?.childId || null,
|
||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||
canAlarm:
|
||||
!!assignment &&
|
||||
plan?.status === "PUBLISHED" &&
|
||||
day.toISOString().slice(0, 10) >= todayKey(),
|
||||
};
|
||||
});
|
||||
|
||||
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 className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Settings2 className="h-6 w-6 text-primary" />
|
||||
Notdienst-Planung
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
NOTDIENST · PLANUNG
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Notdienst{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
zuweisen
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
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 className="print:hidden flex shrink-0 items-center gap-2">
|
||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||
<Link
|
||||
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center border-r border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Link>
|
||||
<div className="flex h-9 min-w-36 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
|
||||
{monthName} {targetYear}
|
||||
</div>
|
||||
<Link
|
||||
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, 1))}&month=${getMonth(addMonths(monthDate, 1)) + 1}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center border-l border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Link>
|
||||
</div>
|
||||
<SettingsModal
|
||||
initialMinPerChild={kita.notdienstMinPerChildPerMonth}
|
||||
initialReminderDays={kita.notdienstReminderDaysBefore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<PlanView
|
||||
planId={plan?.id}
|
||||
status={plan?.status}
|
||||
days={daysWithAssignments}
|
||||
allChildren={allChildren}
|
||||
familyStatuses={familyStatuses}
|
||||
requiredDays={requiredDays}
|
||||
respondedFamilies={respondedFamilies}
|
||||
openFamilies={openFamilies}
|
||||
totalFamilies={familyStatuses.length}
|
||||
totalAvailabilitySlots={totalAvailabilitySlots}
|
||||
coverageFactor={coverageFactor}
|
||||
closureCount={closureCount}
|
||||
monthName={monthName}
|
||||
year={targetYear}
|
||||
month={targetMonth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { NotdienstPlanStatus } from "@prisma/client";
|
||||
import { Loader2, Wand2, CheckSquare } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckSquare,
|
||||
Download,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
generatePlanAction,
|
||||
publishPlanAction,
|
||||
updateAssignmentAction,
|
||||
sendNotdienstRemindersAction,
|
||||
resetPlanAction,
|
||||
} from "./actions";
|
||||
|
||||
interface PlanViewProps {
|
||||
planId?: string;
|
||||
status?: NotdienstPlanStatus;
|
||||
days: {
|
||||
date: string; // ISO
|
||||
date: string;
|
||||
assignmentId: string | null;
|
||||
activeAlarmId?: string | null;
|
||||
canAlarm?: boolean;
|
||||
childId: string | null;
|
||||
isOriginallyUnassigned?: boolean;
|
||||
}[];
|
||||
allChildren: {
|
||||
id: string;
|
||||
name: string;
|
||||
parentName: string;
|
||||
familyId: string;
|
||||
}[];
|
||||
familyStatuses: {
|
||||
id: string;
|
||||
name: string;
|
||||
childCount: number;
|
||||
selectedDays: {
|
||||
iso: string;
|
||||
label: string;
|
||||
}[];
|
||||
}[];
|
||||
requiredDays: number;
|
||||
respondedFamilies: number;
|
||||
openFamilies: number;
|
||||
totalFamilies: number;
|
||||
totalAvailabilitySlots: number;
|
||||
coverageFactor: number;
|
||||
closureCount: number;
|
||||
monthName: string;
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
|
||||
export function PlanView({
|
||||
planId,
|
||||
status,
|
||||
days,
|
||||
allChildren,
|
||||
familyStatuses,
|
||||
requiredDays,
|
||||
respondedFamilies,
|
||||
openFamilies,
|
||||
totalFamilies,
|
||||
totalAvailabilitySlots,
|
||||
coverageFactor,
|
||||
closureCount,
|
||||
monthName,
|
||||
year,
|
||||
month,
|
||||
}: PlanViewProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
|
||||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||||
|
||||
const responsePercent =
|
||||
totalFamilies > 0
|
||||
? Math.round((respondedFamilies / totalFamilies) * 100)
|
||||
: 0;
|
||||
|
||||
const handleGenerate = () => {
|
||||
startTransition(async () => {
|
||||
const result = await generatePlanAction();
|
||||
const result = await generatePlanAction(year, month);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
@@ -56,8 +122,34 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendReminders = () => {
|
||||
startTransition(async () => {
|
||||
const result = await sendNotdienstRemindersAction(year, month);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else if (result.success) {
|
||||
toast.success(
|
||||
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!planId) return;
|
||||
startTransition(async () => {
|
||||
const result = await resetPlanAction(planId);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
toast.success("Plan wurde erfolgreich zurückgesetzt.");
|
||||
setResetDialogOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssignmentChange = (date: string, newChildId: string) => {
|
||||
if (!planId || status !== "DRAFT") return;
|
||||
if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return;
|
||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -70,21 +162,182 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
|
||||
});
|
||||
};
|
||||
|
||||
if (!planId && respondedFamilies === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Wand2 className="h-7 w-7 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<h3 className="mb-1 text-xl font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Noch keine Verfügbarkeiten
|
||||
</h3>
|
||||
<p className="mb-5 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Für {monthName} hat noch keine Familie geantwortet. Verschicke
|
||||
zuerst Erinnerungen, damit die automatische Planung belastbar ist.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button disabled={isPending} onClick={handleGenerate}>
|
||||
Trotzdem generieren
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSendReminders}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
<Send className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
Erinnerungen verschicken
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="space-y-5">
|
||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(300px,1fr)] gap-5 max-[1050px]:grid-cols-1">
|
||||
<Card className="p-0">
|
||||
<div className="border-b border-[var(--hairline-soft)] px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Verfügbarkeiten Eltern
|
||||
</h2>
|
||||
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
||||
{respondedFamilies} von {totalFamilies} Familien haben
|
||||
geantwortet · noch {openFamilies} offen
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
|
||||
{responsePercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 h-1 overflow-hidden rounded-full bg-[var(--accent-soft)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent)]"
|
||||
style={{ width: `${responsePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{familyStatuses.map((family) => (
|
||||
<div
|
||||
key={family.id}
|
||||
className="group relative flex items-center gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold uppercase text-[var(--accent-deep)]">
|
||||
{family.name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-[var(--ink)]">
|
||||
{family.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{family.childCount} Kind
|
||||
{family.childCount === 1 ? "" : "er"}
|
||||
</p>
|
||||
</div>
|
||||
<FamilyStatusPill selectedDays={family.selectedDays.length} />
|
||||
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
|
||||
{family.selectedDays.length > 0
|
||||
? family.selectedDays.map((day) => day.label).join(" · ")
|
||||
: "Noch keine Tage markiert"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSendReminders}
|
||||
disabled={isPending || openFamilies === 0}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
<Send className="h-4 w-4 [stroke-width:1.7]" />
|
||||
)}
|
||||
Erinnerung an offene senden ({openFamilies})
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Bedarfs-Übersicht
|
||||
</h2>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
Planungsgrundlage für {monthName}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<PlanMetric
|
||||
label={`Tage benötigt in ${monthName}`}
|
||||
value={requiredDays}
|
||||
/>
|
||||
<PlanMetric
|
||||
label="Verfügbarkeiten gesamt"
|
||||
value={totalAvailabilitySlots}
|
||||
/>
|
||||
<PlanMetric
|
||||
label="Überdeckung"
|
||||
value={`${coverageFactor.toFixed(1)}×`}
|
||||
badge="good"
|
||||
/>
|
||||
<PlanMetric label="Schließtage" value={closureCount} />
|
||||
</div>
|
||||
<div className="mt-5 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{coverageFactor >= 1
|
||||
? "Du hast genug Verfügbarkeiten für einen Plan."
|
||||
: "Die Verfügbarkeiten reichen noch nicht für einen belastbaren Plan."}
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
<Card variant="featured" className="sticky bottom-6 z-10 p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[760px]:flex-col max-[760px]:items-start">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Wand2 className="h-5 w-5 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Plan automatisch generieren
|
||||
</h3>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
||||
{respondedFamilies} Familien.
|
||||
</p>
|
||||
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
||||
Faire Verteilung berücksichtigt
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<span>☑ Quote pro Familie</span>
|
||||
<span>☑ Bevorzugte Tage</span>
|
||||
<span>☐ Geschwister-Familien zusammen</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleGenerate} disabled={isPending}>
|
||||
{isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
)}
|
||||
Generieren
|
||||
<ArrowRight className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,95 +345,346 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
|
||||
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="space-y-5">
|
||||
<div className="print:hidden flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||
<TabButton
|
||||
active={activeTab === "preview"}
|
||||
onClick={() => setActiveTab("preview")}
|
||||
>
|
||||
Plan-Vorschau
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={activeTab === "manual"}
|
||||
onClick={() => setActiveTab("manual")}
|
||||
withDivider
|
||||
>
|
||||
Manuell zuweisen
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isDraft && (
|
||||
<Button onClick={handlePublish} disabled={isPending}>
|
||||
{isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
)}
|
||||
<CheckSquare className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Plan veröffentlichen
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => window.print()}>
|
||||
<Download className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Als PDF exportieren
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setResetDialogOpen(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
|
||||
Plan zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="print:hidden flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={isDraft ? "warning" : "success"}>
|
||||
{isDraft ? "Entwurf" : "Veröffentlicht"}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-[var(--ink-soft)]">
|
||||
{isDraft
|
||||
? "Du kannst die Liste jetzt manuell anpassen."
|
||||
: "Dieser Plan ist verbindlich."}
|
||||
: "Dieser Plan ist veröffentlicht und kann bei Änderungen nachbearbeitet werden."}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{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>
|
||||
{activeTab === "preview" ? (
|
||||
<PlanPreviewTable days={days} allChildren={allChildren} />
|
||||
) : (
|
||||
<ManualAssignmentTable
|
||||
days={days}
|
||||
allChildren={allChildren}
|
||||
isDraft={isDraft}
|
||||
isPending={isPending}
|
||||
onAssignmentChange={handleAssignmentChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Plan zurücksetzen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für
|
||||
diesen Monat gehen unwiderruflich verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setResetDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleReset}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Zurücksetzen..." : "Ja, Plan zurücksetzen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
|
||||
if (selectedDays === 0) {
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
||||
Noch nicht
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
|
||||
{selectedDays} Tage
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanMetric({
|
||||
label,
|
||||
value,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
badge?: "good";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-[var(--hairline-soft)] pb-3 last:border-b-0 last:pb-0">
|
||||
<span className="text-[13.5px] text-[var(--ink-soft)]">{label}</span>
|
||||
{badge ? (
|
||||
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-sm font-semibold text-[var(--good)] tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xl font-medium tracking-[-0.025em] text-[var(--ink)] tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
withDivider,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
withDivider?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"h-9 px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
|
||||
withDivider ? "border-l border-[var(--hairline-soft)]" : "",
|
||||
active
|
||||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanPreviewTable({
|
||||
days,
|
||||
allChildren,
|
||||
}: Pick<PlanViewProps, "days" | "allChildren">) {
|
||||
return (
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="grid grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
<div>Datum</div>
|
||||
<div>Wochentag</div>
|
||||
<div>Zugewiesen</div>
|
||||
<div>Backup</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{days.map((day) => {
|
||||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||
const dateObj = new Date(yr, mo - 1, dy);
|
||||
const assignedChild = allChildren.find(
|
||||
(child) => child.id === day.childId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="grid cursor-pointer grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] items-center p-4 text-sm transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="font-medium tabular-nums text-[var(--ink)]">
|
||||
{format(dateObj, "dd.MM.", { locale: de })}
|
||||
</div>
|
||||
<div className="text-[var(--ink-soft)]">
|
||||
{format(dateObj, "EEEE", { locale: de })}
|
||||
</div>
|
||||
<div className="text-[var(--ink)]">
|
||||
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
|
||||
{assignedChild && (
|
||||
<span className="ml-1 text-[var(--ink-muted)]">
|
||||
({assignedChild.name})
|
||||
</span>
|
||||
)}
|
||||
{day.activeAlarmId ? (
|
||||
<Link
|
||||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||
href={`/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`}
|
||||
>
|
||||
Aktiven Alarm öffnen
|
||||
</Link>
|
||||
) : day.canAlarm && day.assignmentId ? (
|
||||
<Link
|
||||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||
href={`/dashboard/notdienst/alarm?assignment=${day.assignmentId}`}
|
||||
>
|
||||
Notdienstalarm auslösen
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||
<div>
|
||||
<Badge variant={assignedChild ? "success" : "destructive"}>
|
||||
{assignedChild ? "Besetzt" : "Offen"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualAssignmentTable({
|
||||
days,
|
||||
allChildren,
|
||||
isDraft,
|
||||
isPending,
|
||||
onAssignmentChange,
|
||||
}: Pick<PlanViewProps, "days" | "allChildren"> & {
|
||||
isDraft: boolean;
|
||||
isPending: boolean;
|
||||
onAssignmentChange: (date: string, newChildId: string) => void;
|
||||
}) {
|
||||
// Map childId to familyId for quick lookup
|
||||
const childFamilyMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
allChildren.forEach((child) => {
|
||||
if (child.familyId) {
|
||||
map.set(child.id, child.familyId);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [allChildren]);
|
||||
|
||||
// Compute family assignment counts in real-time based on current selections
|
||||
const familyAssignmentCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
days.forEach((day) => {
|
||||
if (day.childId) {
|
||||
const famId = childFamilyMap.get(day.childId);
|
||||
if (famId) {
|
||||
counts.set(famId, (counts.get(famId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [days, childFamilyMap]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
<div>Datum</div>
|
||||
<div>Eingeteilte Familie</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{days.map((day) => {
|
||||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||
const dateObj = new Date(yr, mo - 1, dy);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="font-medium text-[var(--ink)]">
|
||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
value={day.childId || "empty"}
|
||||
disabled={isPending || !!day.activeAlarmId}
|
||||
onChange={(event) =>
|
||||
onAssignmentChange(day.date, event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="empty">-- Unbesetzt --</option>
|
||||
{allChildren.map((child) => {
|
||||
const famId = child.familyId;
|
||||
const count = famId
|
||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<option key={child.id} value={child.id}>
|
||||
{child.parentName} ({child.name}) — {count}x
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{!isDraft && (
|
||||
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
|
||||
Veröffentlicht
|
||||
</span>
|
||||
)}
|
||||
{day.assignmentId && day.canAlarm && (
|
||||
<Link
|
||||
className="text-xs font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||
href={
|
||||
day.activeAlarmId
|
||||
? `/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`
|
||||
: `/dashboard/notdienst/alarm?assignment=${day.assignmentId}`
|
||||
}
|
||||
>
|
||||
{day.activeAlarmId
|
||||
? "Aktiven Alarm öffnen"
|
||||
: "Notdienstalarm auslösen"}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
+229
-304
@@ -1,81 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import type { ComponentType } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
|
||||
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus } from "@prisma/client";
|
||||
import {
|
||||
CalendarCheck2,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
ListTodo,
|
||||
Users,
|
||||
type LucideProps,
|
||||
} from "lucide-react";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getTargetMonthData } from "@/lib/date-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 { Stat } from "@/components/ui/stat";
|
||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||
import { AlertButton } from "./alert-button";
|
||||
import { AbsenceCard } from "./absence-card";
|
||||
import { NewsTicker } from "./news-ticker";
|
||||
import { RsvpActionCard } from "./rsvp-action-card";
|
||||
import { getAnnouncements } from "@/lib/announcements";
|
||||
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
|
||||
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
|
||||
|
||||
export const metadata = { title: "Übersicht · Kita-Planer" };
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireKitaSession();
|
||||
const now = new Date();
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const targetData = getTargetMonthData();
|
||||
const targetMonthStart = new Date(
|
||||
Date.UTC(targetData.targetYear, targetData.targetMonth - 1, 1),
|
||||
);
|
||||
const targetMonthEnd = new Date(
|
||||
Date.UTC(targetData.targetYear, targetData.targetMonth, 1),
|
||||
);
|
||||
|
||||
const kita = await prisma.kita.findUniqueOrThrow({
|
||||
where: { id: session.user.kitaId },
|
||||
select: {
|
||||
name: true,
|
||||
notdienstModuleEnabled: true,
|
||||
notdienstMinPerChildPerMonth: true,
|
||||
terminModuleEnabled: true,
|
||||
adressbuchModuleEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
|
||||
const [
|
||||
familyCount,
|
||||
childCount,
|
||||
upcomingDuties,
|
||||
todayAbsenceCount,
|
||||
notdienstAvailabilityCount,
|
||||
onboardingUser,
|
||||
absenceChildren,
|
||||
upcomingAbsences,
|
||||
latestAnnouncements,
|
||||
announcements,
|
||||
rsvpEvents,
|
||||
] = await Promise.all([
|
||||
prisma.family.count({
|
||||
where: { kitaId: session.user.kitaId },
|
||||
}),
|
||||
prisma.child.count({
|
||||
where: { kitaId: session.user.kitaId },
|
||||
where: { kitaId: session.user.kitaId, active: true },
|
||||
}),
|
||||
prisma.absence.count({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
startDate: { lte: today },
|
||||
endDate: { gte: today },
|
||||
},
|
||||
}),
|
||||
prisma.notdienstAvailability.count({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
date: {
|
||||
gte: targetMonthStart,
|
||||
lt: targetMonthEnd,
|
||||
},
|
||||
},
|
||||
}),
|
||||
session.user.familyId
|
||||
? prisma.dutyAssignment.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
status: DutyAssignmentStatus.PLANNED,
|
||||
endDate: { gte: today },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dutyType: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
take: 5,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
@@ -140,45 +149,27 @@ export default async function DashboardPage() {
|
||||
take: 6,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
prisma.announcement.findMany({
|
||||
where: { kitaId: session.user.kitaId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
author: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileUrl: true,
|
||||
fileType: true,
|
||||
},
|
||||
},
|
||||
reads: {
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 3,
|
||||
}),
|
||||
getAnnouncements(session, { take: 3 }),
|
||||
getRsvpEvents(session, { onlyOpen: true }),
|
||||
]);
|
||||
|
||||
const isAdmin =
|
||||
session.user.role === UserRole.ADMIN ||
|
||||
session.user.role === UserRole.SUPERADMIN;
|
||||
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
|
||||
const requiredNotdienstDays = Math.max(
|
||||
childCount * kita.notdienstMinPerChildPerMonth,
|
||||
1,
|
||||
);
|
||||
const openNotdienstDays = Math.max(
|
||||
requiredNotdienstDays - notdienstAvailabilityCount,
|
||||
0,
|
||||
);
|
||||
const todayPresentCount = Math.max(childCount - todayAbsenceCount, 0);
|
||||
const firstName =
|
||||
session.user.name?.split(" ")[0] ??
|
||||
session.user.email?.split("@")[0] ??
|
||||
"zusammen";
|
||||
|
||||
return (
|
||||
<div className="px-8 py-8">
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<OnboardingDialog
|
||||
user={{
|
||||
phone: onboardingUser.phone,
|
||||
@@ -195,247 +186,181 @@ export default async function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<header className="mb-9 flex items-start justify-between gap-8 max-[760px]:flex-col max-[760px]:gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
ÜBERSICHT · {kita.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Willkommen{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
zurück
|
||||
</em>
|
||||
, {firstName}.
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
|
||||
siehst du auf einen Blick, was bei {kita.name} ansteht.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-9 max-[760px]:mt-0 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(now, "EEE, d. MMM yyyy", { locale: de })}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AlarmDashboardHints />
|
||||
<RsvpActionCard events={rsvpEvents} />
|
||||
<NewsTicker items={announcements} />
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
{session.user.role === UserRole.ERZIEHER && (
|
||||
<QuickCard
|
||||
href="/dashboard/personal"
|
||||
icon={Clock3}
|
||||
title="Meine Arbeitszeit & Urlaub"
|
||||
sub="Stunden erfassen, Urlaub beantragen und dein Zeitkonto ansehen."
|
||||
/>
|
||||
)}
|
||||
{canReportAbsences ? (
|
||||
<AbsenceCard
|
||||
today={format(today, "yyyy-MM-dd")}
|
||||
childOptions={absenceChildren.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
}))}
|
||||
absences={upcomingAbsences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: format(absence.startDate, "yyyy-MM-dd"),
|
||||
endDate: format(absence.endDate, "yyyy-MM-dd"),
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<UpcomingAbsencesCard
|
||||
absences={upcomingAbsences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: format(absence.startDate, "yyyy-MM-dd"),
|
||||
endDate: format(absence.endDate, "yyyy-MM-dd"),
|
||||
}))}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<NewsTicker
|
||||
items={latestAnnouncements.map((announcement) => ({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
}),
|
||||
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
|
||||
isUnread: announcement.reads.length === 0,
|
||||
attachments: announcement.attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
fileUrl: attachment.fileUrl,
|
||||
fileType: attachment.fileType,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
|
||||
{isAdmin && (
|
||||
<div className="mb-8">
|
||||
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReportAbsences && (
|
||||
<AbsenceCard
|
||||
today={format(today, "yyyy-MM-dd")}
|
||||
childOptions={absenceChildren.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
}))}
|
||||
absences={upcomingAbsences.map((absence) => ({
|
||||
id: absence.id,
|
||||
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||
reason: absence.reason,
|
||||
note: absence.note,
|
||||
startDate: format(absence.startDate, "yyyy-MM-dd"),
|
||||
endDate: format(absence.endDate, "yyyy-MM-dd"),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{upcomingDuties.length > 0 && (
|
||||
<Card className="mb-8 border-emerald-200 bg-emerald-50/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-700" />
|
||||
Deine anstehenden Dienste
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Die nächsten Einteilungen für deinen Haushalt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{upcomingDuties.map((duty) => (
|
||||
<div key={duty.id} className="rounded-md border bg-background p-3">
|
||||
<p className="font-medium">{duty.dutyType.name}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "}
|
||||
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Statistik-Kacheln */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard
|
||||
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
|
||||
<Stat
|
||||
label="Familien"
|
||||
value={familyCount}
|
||||
description="registrierte Familien"
|
||||
icon={<Users className="h-5 w-5 text-muted-foreground" />}
|
||||
hint="registrierte Familien"
|
||||
/>
|
||||
<StatCard
|
||||
label="Kinder"
|
||||
value={childCount}
|
||||
description="in der Einrichtung"
|
||||
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />}
|
||||
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
|
||||
<Stat
|
||||
label={`Notdienst ${targetData.monthName}`}
|
||||
value={notdienstAvailabilityCount}
|
||||
suffix={`/ ${requiredNotdienstDays}`}
|
||||
hint={`Tage abgedeckt · ${openNotdienstDays} offen`}
|
||||
/>
|
||||
</div>
|
||||
<Stat
|
||||
label="Heute anwesend"
|
||||
value={todayPresentCount}
|
||||
hint="Kinder in der Kita"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<section className="mt-7">
|
||||
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||||
Schnellstart
|
||||
</h2>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
Häufige Aktionen für Vorstand und Koordination.
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
|
||||
{session.user.role === UserRole.ADMIN && (
|
||||
<QuickCard
|
||||
href="/dashboard/personal"
|
||||
icon={Clock3}
|
||||
title="Personal & Freigaben"
|
||||
sub="Urlaub, Arbeitswochen und Beschäftigungsprofile verwalten."
|
||||
/>
|
||||
)}
|
||||
{session.user.role === UserRole.ADMIN && (
|
||||
<QuickCard
|
||||
href="/dashboard/families"
|
||||
icon={Users}
|
||||
title="Familien verwalten"
|
||||
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
|
||||
/>
|
||||
)}
|
||||
{kita.terminModuleEnabled ? (
|
||||
<QuickCard
|
||||
href="/dashboard/kalender"
|
||||
icon={CalendarClock}
|
||||
title="Terminkalender"
|
||||
sub="Schließtage, Elternabende und Feste planen."
|
||||
/>
|
||||
) : null}
|
||||
{kita.notdienstModuleEnabled &&
|
||||
[UserRole.ADMIN, UserRole.KOORDINATOR].includes(
|
||||
session.user.role as "ADMIN" | "KOORDINATOR",
|
||||
) ? (
|
||||
<QuickCard
|
||||
href="/dashboard/notdienst/plan"
|
||||
icon={ListTodo}
|
||||
title="Notdienst-Planung"
|
||||
sub="Verfügbarkeiten überblicken und Dienste zuweisen."
|
||||
/>
|
||||
) : null}
|
||||
{kita.adressbuchModuleEnabled ? (
|
||||
<QuickCard
|
||||
href="/dashboard/adressbuch"
|
||||
icon={CalendarCheck2}
|
||||
title="Adressbuch"
|
||||
sub="Kontakte der Kita-Gemeinschaft schnell finden."
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
function QuickCard({
|
||||
href,
|
||||
icon: Icon,
|
||||
title,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
href: string;
|
||||
icon: ComponentType<LucideProps>;
|
||||
title: string;
|
||||
sub: string;
|
||||
}) {
|
||||
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 },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) return null;
|
||||
|
||||
const parentNames = assignment.child.family.users
|
||||
.map((parent) => `${parent.firstName} ${parent.lastName}`)
|
||||
.join(", ");
|
||||
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">
|
||||
{parentNames || assignment.child.family.name}
|
||||
</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} />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex min-h-[86px] items-center gap-4 rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-4 transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Icon className="h-4.5 w-4.5 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-[var(--ink)]">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-[var(--ink-soft)]">
|
||||
{sub}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--ink-faint)] transition group-hover:translate-x-1 group-hover:text-[var(--accent-deep)]" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireStaffActor } from "@/lib/staff/auth";
|
||||
import { executeStaffCommand } from "@/lib/staff/service";
|
||||
import { deliverStaffEmails } from "@/lib/staff/email";
|
||||
|
||||
export type StaffActionResult = {
|
||||
educatorId?: string;
|
||||
error?: string;
|
||||
success?: boolean;
|
||||
invitePath?: string;
|
||||
warning?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function staffAction(
|
||||
command: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<StaffActionResult> {
|
||||
const actor = await requireStaffActor();
|
||||
try {
|
||||
const result = await executeStaffCommand(actor, { command, data });
|
||||
let warning: string | undefined;
|
||||
let message: string | undefined;
|
||||
if (result.emailIds?.length) {
|
||||
try {
|
||||
const delivery = await deliverStaffEmails(result.emailIds);
|
||||
if (delivery.failed)
|
||||
warning =
|
||||
"Gespeichert. Einige E-Mails konnten nicht versendet werden. Im Personalbereich kannst du den Versand erneut versuchen.";
|
||||
else if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.RESEND_API_KEY
|
||||
)
|
||||
warning = "Gespeichert. Der E-Mail-Versand wurde lokal simuliert.";
|
||||
else if (result.invite && delivery.sent)
|
||||
message = "Aktivierungslink per E-Mail versendet.";
|
||||
else if (command === "emailRetry")
|
||||
message = "Ausstehende E-Mails wurden verarbeitet.";
|
||||
} catch {
|
||||
warning =
|
||||
"Gespeichert. E-Mail-Versand ist noch ausstehend; bitte im Personalbereich erneut versuchen.";
|
||||
}
|
||||
}
|
||||
revalidatePath("/dashboard/personal");
|
||||
revalidatePath("/dashboard/profil");
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
revalidatePath("/dashboard");
|
||||
// Returned only to the authenticated admin who generated this single-use link.
|
||||
return {
|
||||
success: true,
|
||||
educatorId: result.educatorId,
|
||||
warning,
|
||||
message,
|
||||
...(result.invite
|
||||
? { invitePath: `/invite/${result.invite.token}` }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return {
|
||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
};
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes("prisma") &&
|
||||
!error.message.includes("Invalid `")
|
||||
)
|
||||
return { error: error.message };
|
||||
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, useTransition, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DAY_NAMES,
|
||||
clockMinute,
|
||||
minuteClock,
|
||||
todayKey,
|
||||
} from "@/lib/staff/calculations";
|
||||
import { staffAction, type StaffActionResult } from "./actions";
|
||||
|
||||
const control =
|
||||
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
|
||||
export function Field({
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
value,
|
||||
required = true,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string | number;
|
||||
required?: boolean;
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
step?: string | number;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={value}
|
||||
required={required}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function transform(command: string, form: FormData) {
|
||||
const text = (key: string) => String(form.get(key) ?? "");
|
||||
const num = (key: string) => Number(text(key));
|
||||
const hours = (key: string) => Math.round(num(key) * 60);
|
||||
switch (command) {
|
||||
case "emailPreference":
|
||||
return { enabled: form.get("enabled") === "on" };
|
||||
case "setup":
|
||||
return {
|
||||
educatorId: text("educatorId"),
|
||||
employmentStart: text("employmentStart"),
|
||||
employmentEnd: text("employmentEnd") || null,
|
||||
trackingStart: text("trackingStart"),
|
||||
openingMinutes: hours("openingHours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "invite":
|
||||
return { email: text("email") };
|
||||
case "employmentEnd":
|
||||
return {
|
||||
employmentEnd: text("employmentEnd") || null,
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "pattern":
|
||||
return {
|
||||
effectiveFrom: text("effectiveFrom"),
|
||||
dailyMinutes: DAY_NAMES.map((_, i) => hours(`day${i}`)),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "account":
|
||||
return {
|
||||
year: num("year"),
|
||||
entitlementUnits: Math.round(num("entitlement") * 2),
|
||||
carryUnits: Math.round(num("carry") * 2),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "override":
|
||||
return {
|
||||
date: text("date"),
|
||||
minutes: hours("hours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "adjust":
|
||||
return {
|
||||
date: text("date"),
|
||||
minutes: hours("hours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "holiday":
|
||||
return { date: text("date"), name: text("name") };
|
||||
case "time":
|
||||
return {
|
||||
date: text("date"),
|
||||
startMinute: clockMinute(text("start")),
|
||||
endMinute: clockMinute(text("end")),
|
||||
breakMinutes: num("breakMinutes"),
|
||||
note: text("note"),
|
||||
};
|
||||
case "leave":
|
||||
return {
|
||||
kind: text("kind"),
|
||||
startDate: text("startDate"),
|
||||
endDate: text("endDate"),
|
||||
halfDay: form.get("halfDay") === "on",
|
||||
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
||||
note: text("note"),
|
||||
};
|
||||
case "leaveReview":
|
||||
case "weekReview":
|
||||
return { reason: text("reason") };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function StaffForm({
|
||||
command,
|
||||
profileId,
|
||||
extra = {},
|
||||
children,
|
||||
label = "Speichern",
|
||||
onSuccess,
|
||||
}: {
|
||||
command: string;
|
||||
profileId?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
onSuccess?: (result: StaffActionResult) => void;
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
try {
|
||||
const result = await staffAction(command, {
|
||||
...transform(command, form),
|
||||
...extra,
|
||||
...(profileId ? { profileId } : {}),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
onSuccess?.(result);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Speichern fehlgeschlagen.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
||||
{children}
|
||||
<div>
|
||||
<Button type="submit">
|
||||
{pending ? "Wird gespeichert …" : label}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
export function CommandButton({
|
||||
command,
|
||||
profileId,
|
||||
data,
|
||||
children,
|
||||
variant = "outline",
|
||||
}: {
|
||||
command: string;
|
||||
profileId: string;
|
||||
data: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
variant?: "outline" | "ghost" | "default";
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const result = await staffAction(command, { ...data, profileId });
|
||||
if (result.error) toast.error(result.error);
|
||||
else {
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
router.refresh();
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Bitte warten …" : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export function ReviewButton({
|
||||
command,
|
||||
profileId,
|
||||
data,
|
||||
label,
|
||||
approve,
|
||||
}: {
|
||||
command: "leaveReview" | "weekReview";
|
||||
profileId: string;
|
||||
data: Record<string, unknown>;
|
||||
label: string;
|
||||
approve: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant={approve ? "default" : "outline"}>
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die Entscheidung wird im Personalverlauf dokumentiert.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command={command}
|
||||
profileId={profileId}
|
||||
extra={{ ...data, approve }}
|
||||
label={label}
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<Field label="Begründung / Rückmeldung" name="reason" />
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function SetupForm({
|
||||
educatorId,
|
||||
values,
|
||||
}: {
|
||||
educatorId: string;
|
||||
values?: {
|
||||
employmentStart: string;
|
||||
employmentEnd: string | null;
|
||||
trackingStart: string;
|
||||
openingMinutes: number;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<StaffForm
|
||||
command="setup"
|
||||
extra={{ educatorId }}
|
||||
label="Beschäftigungsprofil speichern"
|
||||
>
|
||||
<input type="hidden" name="educatorId" value={educatorId} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="Beschäftigung ab"
|
||||
name="employmentStart"
|
||||
type="date"
|
||||
value={values?.employmentStart ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Beschäftigung bis (optional)"
|
||||
name="employmentEnd"
|
||||
type="date"
|
||||
value={values?.employmentEnd ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<Field
|
||||
label="Zeiterfassung ab"
|
||||
name="trackingStart"
|
||||
type="date"
|
||||
value={values?.trackingStart ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Anfangssaldo in Stunden"
|
||||
name="openingHours"
|
||||
type="number"
|
||||
step="any"
|
||||
value={(values?.openingMinutes ?? 0) / 60}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" value="Ersteinrichtung" />
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
export function TimeForm({
|
||||
profileId,
|
||||
initial,
|
||||
}: {
|
||||
profileId: string;
|
||||
initial?: {
|
||||
id: string;
|
||||
date: string;
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
breakMinutes: number;
|
||||
note: string | null;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={initial ? "outline" : "default"}
|
||||
size={initial ? "sm" : "default"}
|
||||
>
|
||||
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initial ? "Arbeitszeit bearbeiten" : "Arbeitszeit erfassen"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erfasse Beginn, Ende und die gesamte Pause dieses Arbeitsblocks.
|
||||
Weitere Blöcke am selben Tag sind möglich.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command="time"
|
||||
profileId={profileId}
|
||||
extra={initial ? { id: initial.id } : {}}
|
||||
label="Arbeitszeit speichern"
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<Field
|
||||
label="Datum"
|
||||
name="date"
|
||||
type="date"
|
||||
value={initial?.date ?? todayKey()}
|
||||
max={todayKey()}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Beginn"
|
||||
name="start"
|
||||
type="time"
|
||||
value={minuteClock(initial?.startMinute ?? 480)}
|
||||
/>
|
||||
<Field
|
||||
label="Ende"
|
||||
name="end"
|
||||
type="time"
|
||||
value={minuteClock(initial?.endMinute ?? 960)}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Pause in Minuten"
|
||||
name="breakMinutes"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={initial?.breakMinutes ?? 30}
|
||||
/>
|
||||
<Field
|
||||
label="Notiz (optional)"
|
||||
name="note"
|
||||
value={initial?.note ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function LeaveForm({
|
||||
profileId,
|
||||
admin,
|
||||
initial,
|
||||
}: {
|
||||
profileId: string;
|
||||
admin: boolean;
|
||||
initial?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
halfDay: boolean;
|
||||
minutes: number | null;
|
||||
note: string | null;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
||||
const id = useId();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size={initial ? "sm" : "default"} variant="outline">
|
||||
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initial ? "Antrag ändern" : "Abwesenheit beantragen"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Urlaub verbraucht nur deine vorgesehenen Arbeitstage.
|
||||
Freizeitausgleich wird in Stunden für einen einzelnen Tag beantragt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command="leave"
|
||||
profileId={profileId}
|
||||
extra={initial ? { id: initial.id } : {}}
|
||||
label={
|
||||
kind === "SICKNESS" ? "Krankheit hinterlegen" : "Antrag speichern"
|
||||
}
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor={`${id}-kind`} className="text-sm font-medium">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id={`${id}-kind`}
|
||||
name="kind"
|
||||
className={control}
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
<option value="VACATION">Urlaub</option>
|
||||
<option value="TIME_OFF">Freizeitausgleich</option>
|
||||
{admin && <option value="SICKNESS">Krankheit</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Von"
|
||||
name="startDate"
|
||||
type="date"
|
||||
value={initial?.startDate ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Bis"
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={initial?.endDate ?? todayKey()}
|
||||
/>
|
||||
</div>
|
||||
{kind === "VACATION" && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
name="halfDay"
|
||||
type="checkbox"
|
||||
defaultChecked={initial?.halfDay}
|
||||
/>
|
||||
Halber Urlaubstag (Von = Bis)
|
||||
</label>
|
||||
)}
|
||||
{kind === "TIME_OFF" && (
|
||||
<Field
|
||||
label="Freizeitausgleich in Stunden (Von = Bis)"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={1 / 60}
|
||||
max={16}
|
||||
step="any"
|
||||
value={(initial?.minutes ?? 60) / 60}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Notiz (optional)"
|
||||
name="note"
|
||||
value={initial?.note ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Der Antrag wird anhand deines Wochenmodells, der Tagesausnahmen und
|
||||
der Feiertage geprüft. Ein halber Tag entspricht der Hälfte der
|
||||
Sollzeit.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
function SettingsSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5">
|
||||
<summary className="cursor-pointer font-semibold">{title}</summary>
|
||||
<div className="mt-5">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export function StaffEmailSettings({ enabled }: { enabled: boolean }) {
|
||||
return (
|
||||
<StaffForm command="emailPreference" label="E-Mail-Einstellung speichern">
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
defaultChecked={enabled}
|
||||
className="mt-1 accent-[var(--accent-deep)]"
|
||||
/>
|
||||
<span>
|
||||
Entscheidungen zu Urlaub, Freizeitausgleich und Arbeitswochen per
|
||||
E-Mail erhalten.
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Aktivierungslinks und Hinweise innerhalb der App bleiben davon
|
||||
unabhängig.
|
||||
</p>
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
profileId,
|
||||
educatorId,
|
||||
employment,
|
||||
email,
|
||||
verified,
|
||||
pattern,
|
||||
year,
|
||||
account,
|
||||
}: {
|
||||
profileId: string;
|
||||
educatorId: string;
|
||||
employment: {
|
||||
employmentStart: string;
|
||||
employmentEnd: string | null;
|
||||
trackingStart: string;
|
||||
openingMinutes: number;
|
||||
};
|
||||
email?: string;
|
||||
verified: boolean;
|
||||
pattern?: { effectiveFrom: string; dailyMinutes: number[] };
|
||||
year: number;
|
||||
account?: { entitlementUnits: number; carryUnits: number };
|
||||
}) {
|
||||
const [invitePath, setInvitePath] = useState("");
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<SettingsSection title="Zugang zur Zeiterfassung">
|
||||
{verified ? (
|
||||
<p className="text-sm">Aktives Konto: {email}</p>
|
||||
) : (
|
||||
<StaffForm
|
||||
command="invite"
|
||||
profileId={profileId}
|
||||
label="Per E-Mail einladen / Account verknüpfen"
|
||||
onSuccess={(r) => setInvitePath(r.invitePath ?? "")}
|
||||
>
|
||||
<Field
|
||||
label="E-Mail-Adresse"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email ?? ""}
|
||||
/>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
|
||||
persönliche Aktivierungslink wird per E-Mail versendet und ist
|
||||
sieben Tage gültig. Ein neuer Link ersetzt den vorherigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
)}
|
||||
{invitePath && (
|
||||
<div className="mt-4 grid gap-2 rounded-lg bg-[var(--accent-soft)] p-4">
|
||||
<label htmlFor="activation-link" className="text-sm font-medium">
|
||||
Persönlicher Aktivierungslink
|
||||
</label>
|
||||
<Input
|
||||
id="activation-link"
|
||||
readOnly
|
||||
value={`${window.location.origin}${invitePath}`}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`${window.location.origin}${invitePath}`,
|
||||
);
|
||||
toast.success("Link kopiert.");
|
||||
}}
|
||||
>
|
||||
Link kopieren
|
||||
</Button>
|
||||
<p className="text-xs">
|
||||
Der Link wird per E-Mail versendet. Bei Bedarf kannst du ihn
|
||||
zusätzlich persönlich an die Fachkraft weitergeben.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Feste Wochentage & Sollstunden">
|
||||
<StaffForm
|
||||
command="pattern"
|
||||
profileId={profileId}
|
||||
label="Wochenmodell speichern"
|
||||
>
|
||||
<Field
|
||||
label="Gültig ab"
|
||||
name="effectiveFrom"
|
||||
type="date"
|
||||
value={pattern?.effectiveFrom ?? employment.trackingStart}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{DAY_NAMES.map((day, i) => (
|
||||
<Field
|
||||
key={day}
|
||||
label={`${day} · Stunden`}
|
||||
name={`day${i}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
step="any"
|
||||
value={(pattern?.dailyMinutes[i] ?? 0) / 60}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
0 Stunden = frei. Stunden sind Nettozeiten ohne Pause. Änderungen
|
||||
gelten ab dem gewählten Datum; ältere Modelle bleiben erhalten.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={`Urlaubsanspruch & Übertrag · ${year}`}>
|
||||
<StaffForm command="account" profileId={profileId}>
|
||||
<Field
|
||||
label="Jahr"
|
||||
name="year"
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2100}
|
||||
value={year}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Jahresanspruch in Tagen"
|
||||
name="entitlement"
|
||||
type="number"
|
||||
min={0}
|
||||
max={400}
|
||||
step={0.5}
|
||||
value={(account?.entitlementUnits ?? 0) / 2}
|
||||
/>
|
||||
<Field
|
||||
label="Übertrag in Tagen"
|
||||
name="carry"
|
||||
type="number"
|
||||
min={0}
|
||||
max={400}
|
||||
step={0.5}
|
||||
value={(account?.carryUnits ?? 0) / 2}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Anspruch individuell für die Arbeitstage dieser Person hinterlegen.
|
||||
Der Übertrag wird ausdrücklich festgelegt.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Ausnahme für einen einzelnen Tag">
|
||||
<StaffForm command="override" profileId={profileId}>
|
||||
<Field label="Datum" name="date" type="date" value={todayKey()} />
|
||||
<Field
|
||||
label="Abweichende Sollstunden (0 = frei)"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
step="any"
|
||||
value={0}
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Bei einem Tausch beide Tage erfassen: den ursprünglichen Tag auf 0
|
||||
setzen und den neuen Arbeitstag mit Sollstunden hinterlegen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Zeitkonto korrigieren">
|
||||
<StaffForm command="adjust" profileId={profileId}>
|
||||
<Field
|
||||
label="Buchungsdatum"
|
||||
name="date"
|
||||
type="date"
|
||||
value={todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Korrektur in Stunden (mit Vorzeichen)"
|
||||
name="hours"
|
||||
type="number"
|
||||
step="any"
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Korrekturen bleiben im Verlauf erhalten. Falsche Buchungen durch
|
||||
eine Gegenbuchung berichtigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Beschäftigungsbeginn & Anfangssaldo">
|
||||
<SetupForm educatorId={educatorId} values={employment} />
|
||||
<p className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||
Die Grunddaten sind geschützt, sobald Zeiten, Wochen oder Anträge
|
||||
erfasst wurden.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Beschäftigungsende ändern">
|
||||
<StaffForm command="employmentEnd" profileId={profileId}>
|
||||
<Field
|
||||
label="Beschäftigung bis (leer = unbefristet)"
|
||||
name="employmentEnd"
|
||||
type="date"
|
||||
value={employment.employmentEnd ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function HolidayForm() {
|
||||
return (
|
||||
<StaffForm command="holiday" label="Feiertag speichern">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Datum" name="date" type="date" />
|
||||
<Field label="Bezeichnung" name="name" />
|
||||
</div>
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,10 +16,12 @@ type ContactFormProps = {
|
||||
street: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
directoryOptIn: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function ContactForm({ contact }: ContactFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(formData: FormData) {
|
||||
@@ -28,6 +31,7 @@ export function ContactForm({ contact }: ContactFormProps) {
|
||||
street: String(formData.get("street") ?? ""),
|
||||
postalCode: String(formData.get("postalCode") ?? ""),
|
||||
city: String(formData.get("city") ?? ""),
|
||||
directoryOptIn: formData.get("directoryOptIn") === "on",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -36,13 +40,19 @@ export function ContactForm({ contact }: ContactFormProps) {
|
||||
}
|
||||
|
||||
toast.success("Kontaktdaten gespeichert.");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone">Telefonnummer</Label>
|
||||
<Label
|
||||
htmlFor="phone"
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Telefonnummer
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
@@ -53,7 +63,12 @@ export function ContactForm({ contact }: ContactFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="street">Straße und Hausnummer</Label>
|
||||
<Label
|
||||
htmlFor="street"
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Straße und Hausnummer
|
||||
</Label>
|
||||
<Input
|
||||
id="street"
|
||||
name="street"
|
||||
@@ -62,9 +77,14 @@ export function ContactForm({ contact }: ContactFormProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[120px_1fr]">
|
||||
<div className="grid gap-3 sm:grid-cols-[100px_1fr]">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="postalCode">PLZ</Label>
|
||||
<Label
|
||||
htmlFor="postalCode"
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
PLZ
|
||||
</Label>
|
||||
<Input
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
@@ -73,7 +93,12 @@ export function ContactForm({ contact }: ContactFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Label
|
||||
htmlFor="city"
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Ort
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
@@ -83,7 +108,23 @@ export function ContactForm({ contact }: ContactFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="directoryOptIn"
|
||||
name="directoryOptIn"
|
||||
defaultChecked={contact.directoryOptIn}
|
||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="directoryOptIn"
|
||||
className="font-normal cursor-pointer text-xs text-[var(--ink-soft)]"
|
||||
>
|
||||
Im Adressbuch anzeigen (Datenschutzerklärung)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -23,7 +23,7 @@ export function DeleteAccountDialog() {
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isConfirmed = confirmText === "LÖSCHEN";
|
||||
const isConfirmed = confirmText === "DELETE";
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!isConfirmed) return;
|
||||
@@ -44,18 +44,19 @@ export function DeleteAccountDialog() {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full sm:w-auto">
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Account & Daten endgültig löschen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="border-destructive/30">
|
||||
<DialogContent className="border-[var(--danger)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive flex items-center gap-2">
|
||||
<DialogTitle className="flex items-center gap-2 text-[var(--danger)]">
|
||||
<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.
|
||||
<DialogDescription className="pt-2 text-base">
|
||||
Diese Aktion kann <strong className="text-[var(--ink)]">nicht</strong>{" "}
|
||||
rückgängig gemacht werden.
|
||||
Wenn du das letzte Elternteil im Haushalt bist, wird die gesamte
|
||||
Familie inklusive Kinder gelöscht. Gibt es ein weiteres Elternteil,
|
||||
bleibt der Haushalt für diese Person erhalten und nur dein Account
|
||||
@@ -63,16 +64,17 @@ export function DeleteAccountDialog() {
|
||||
</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 className="my-2 rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-3 text-sm text-[var(--danger)]">
|
||||
Bitte tippe <strong>DELETE</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"
|
||||
placeholder="DELETE"
|
||||
className="border-[var(--danger)] focus-visible:ring-[var(--focus-ring)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { type FormEvent, useState, useTransition } from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getChildBirthdateRange } from "@/lib/child-birthdate";
|
||||
import { createMyChild, deleteMyChild, updateMyChild } from "../actions";
|
||||
|
||||
export type MyChild = {
|
||||
@@ -25,6 +27,18 @@ export type MyChild = {
|
||||
dateOfBirth: string | null;
|
||||
};
|
||||
|
||||
type ChildPayload = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
};
|
||||
|
||||
const emptyChildPayload: ChildPayload = {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
dateOfBirth: "",
|
||||
};
|
||||
|
||||
export function MyChildrenManager({
|
||||
items,
|
||||
canManage,
|
||||
@@ -33,38 +47,41 @@ export function MyChildrenManager({
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createPayload, setCreatePayload] =
|
||||
useState<ChildPayload>(emptyChildPayload);
|
||||
const [editingChild, setEditingChild] = useState<MyChild | null>(null);
|
||||
const [editPayload, setEditPayload] =
|
||||
useState<ChildPayload>(emptyChildPayload);
|
||||
const [childToDelete, setChildToDelete] = useState<MyChild | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function payloadFromForm(formData: FormData) {
|
||||
return {
|
||||
firstName: String(formData.get("firstName") ?? ""),
|
||||
lastName: String(formData.get("lastName") ?? ""),
|
||||
dateOfBirth: String(formData.get("dateOfBirth") ?? ""),
|
||||
};
|
||||
function openEditor(child: MyChild) {
|
||||
setEditingChild(child);
|
||||
setEditPayload(payloadFromChild(child));
|
||||
}
|
||||
|
||||
function handleCreate(formData: FormData) {
|
||||
function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createMyChild(payloadFromForm(formData));
|
||||
const result = await createMyChild(createPayload);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Kind angelegt.");
|
||||
setCreatePayload(emptyChildPayload);
|
||||
setCreateOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdate(formData: FormData) {
|
||||
function handleUpdate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!editingChild) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateMyChild(
|
||||
editingChild.id,
|
||||
payloadFromForm(formData),
|
||||
);
|
||||
const result = await updateMyChild(editingChild.id, editPayload);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
@@ -76,11 +93,6 @@ export function MyChildrenManager({
|
||||
}
|
||||
|
||||
function handleDelete(child: MyChild) {
|
||||
const confirmed = confirm(
|
||||
`${child.firstName} ${child.lastName} wirklich entfernen?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteMyChild(child.id);
|
||||
if (result.error) {
|
||||
@@ -89,37 +101,52 @@ export function MyChildrenManager({
|
||||
}
|
||||
|
||||
toast.success("Kind entfernt.");
|
||||
setChildToDelete(null);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Kinder verknüpft.</p>
|
||||
<p className="text-sm italic text-[var(--ink-faint)]">
|
||||
Keine Kinder verknüpft.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-muted/50 p-2"
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{child.firstName} {child.lastName}
|
||||
</p>
|
||||
{child.dateOfBirth && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Geboren am {new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
|
||||
{getInitials(child)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--ink)]">
|
||||
{child.firstName} {child.lastName}
|
||||
</p>
|
||||
)}
|
||||
{child.dateOfBirth ? (
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Geboren am{" "}
|
||||
{new Date(child.dateOfBirth).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||
Geburtsdatum fehlt
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingChild(child)}
|
||||
className="h-7 w-7"
|
||||
onClick={() => openEditor(child)}
|
||||
disabled={isPending || !canManage}
|
||||
aria-label="Kind bearbeiten"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -127,9 +154,10 @@ export function MyChildrenManager({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(child)}
|
||||
className="h-7 w-7 text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setChildToDelete(child)}
|
||||
disabled={isPending || !canManage}
|
||||
aria-label="Kind löschen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -140,22 +168,32 @@ export function MyChildrenManager({
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) setCreatePayload(emptyChildPayload);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="gap-2">
|
||||
<Button type="button" variant="pill" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
Kind hinzufügen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleCreate}>
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kind hinzufügen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Dieses Kind wird mit deinem Haushalt verknüpft.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ChildFields />
|
||||
<ChildFields
|
||||
idPrefix="create-child"
|
||||
value={createPayload}
|
||||
onChange={setCreatePayload}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -173,7 +211,7 @@ export function MyChildrenManager({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||
Dein Account ist noch keinem Haushalt zugeordnet.
|
||||
</p>
|
||||
)}
|
||||
@@ -183,11 +221,17 @@ export function MyChildrenManager({
|
||||
onOpenChange={(open) => !open && setEditingChild(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<form action={handleUpdate}>
|
||||
<form onSubmit={handleUpdate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kind bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editingChild && <ChildFields child={editingChild} />}
|
||||
{editingChild && (
|
||||
<ChildFields
|
||||
idPrefix="edit-child"
|
||||
value={editPayload}
|
||||
onChange={setEditPayload}
|
||||
/>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -204,40 +248,107 @@ export function MyChildrenManager({
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!childToDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChildToDelete(null);
|
||||
}}
|
||||
title="Kind entfernen?"
|
||||
description={
|
||||
childToDelete
|
||||
? `${childToDelete.firstName} ${childToDelete.lastName} wird aus deinem Haushalt entfernt.`
|
||||
: "Dieses Kind wird aus deinem Haushalt entfernt."
|
||||
}
|
||||
confirmLabel="Entfernen"
|
||||
pendingLabel="Wird entfernt..."
|
||||
isPending={isPending}
|
||||
onConfirm={() => {
|
||||
if (childToDelete) handleDelete(childToDelete);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildFields({ child }: { child?: MyChild }) {
|
||||
function ChildFields({
|
||||
idPrefix,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
value: ChildPayload;
|
||||
onChange: (value: ChildPayload) => void;
|
||||
}) {
|
||||
const birthdateRange = getChildBirthdateRange();
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="child-firstName">Vorname</Label>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-firstName`}
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Vorname
|
||||
</Label>
|
||||
<Input
|
||||
id="child-firstName"
|
||||
id={`${idPrefix}-firstName`}
|
||||
name="firstName"
|
||||
defaultValue={child?.firstName ?? ""}
|
||||
value={value.firstName}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, firstName: event.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="child-lastName">Nachname</Label>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-lastName`}
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Nachname
|
||||
</Label>
|
||||
<Input
|
||||
id="child-lastName"
|
||||
id={`${idPrefix}-lastName`}
|
||||
name="lastName"
|
||||
defaultValue={child?.lastName ?? ""}
|
||||
value={value.lastName}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, lastName: event.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="child-dateOfBirth">Geburtsdatum</Label>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-dateOfBirth`}
|
||||
className="text-xs font-semibold text-[var(--ink-soft)]"
|
||||
>
|
||||
Geburtsdatum
|
||||
</Label>
|
||||
<Input
|
||||
id="child-dateOfBirth"
|
||||
id={`${idPrefix}-dateOfBirth`}
|
||||
name="dateOfBirth"
|
||||
type="date"
|
||||
defaultValue={child?.dateOfBirth ?? ""}
|
||||
min={birthdateRange.min}
|
||||
max={birthdateRange.max}
|
||||
value={value.dateOfBirth}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, dateOfBirth: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function payloadFromChild(child: MyChild): ChildPayload {
|
||||
return {
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function getInitials(child: MyChild) {
|
||||
return `${child.firstName[0] ?? ""}${child.lastName[0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -2,32 +2,95 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { NotdienstPlanStatus } from "@prisma/client";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import {
|
||||
CHILD_MAX_AGE_YEARS,
|
||||
getChildBirthdateRange,
|
||||
parseDateInput,
|
||||
} from "@/lib/child-birthdate";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const childSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
});
|
||||
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
||||
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
|
||||
// Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu.
|
||||
const phonePattern = /^[+]?[0-9][\d\s\-()/.]{4,}$/;
|
||||
|
||||
// PLZ: Ziffern/Buchstaben/Leerzeichen/Bindestriche, 3–10 Zeichen.
|
||||
// Kein striktes DE-Format (5 Ziffern), damit auch AT/CH/UK denkbar sind.
|
||||
const postalCodePattern = /^[0-9A-Za-z][0-9A-Za-z\s-]{2,9}$/;
|
||||
|
||||
const childSchema = z
|
||||
.object({
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.dateOfBirth) return;
|
||||
const parsed = parseDateInput(data.dateOfBirth);
|
||||
if (!parsed) {
|
||||
ctx.addIssue({
|
||||
path: ["dateOfBirth"],
|
||||
code: "custom",
|
||||
message: "Bitte ein gültiges Datum angeben.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { min, max } = getChildBirthdateRange();
|
||||
if (data.dateOfBirth > max) {
|
||||
ctx.addIssue({
|
||||
path: ["dateOfBirth"],
|
||||
code: "custom",
|
||||
message: "Geburtsdatum darf nicht in der Zukunft liegen.",
|
||||
});
|
||||
}
|
||||
if (data.dateOfBirth < min) {
|
||||
ctx.addIssue({
|
||||
path: ["dateOfBirth"],
|
||||
code: "custom",
|
||||
message: `Kinder dürfen maximal ${CHILD_MAX_AGE_YEARS} Jahre alt sein.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const familySchema = z.object({
|
||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
||||
});
|
||||
|
||||
const contactSchema = z.object({
|
||||
phone: z.string().trim().max(50).optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(50)
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || phonePattern.test(v),
|
||||
"Bitte eine gültige Telefonnummer angeben.",
|
||||
),
|
||||
street: z.string().trim().max(120).optional(),
|
||||
postalCode: z.string().trim().max(20).optional(),
|
||||
postalCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(20)
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || postalCodePattern.test(v),
|
||||
"Bitte eine gültige Postleitzahl angeben.",
|
||||
),
|
||||
city: z.string().trim().max(100).optional(),
|
||||
directoryOptIn: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function parseDateInput(value?: string) {
|
||||
if (!value) return null;
|
||||
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
||||
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
|
||||
function firstZodMessage(
|
||||
error: z.ZodError,
|
||||
fallback: string,
|
||||
): string {
|
||||
const issue = error.issues[0];
|
||||
return issue?.message || fallback;
|
||||
}
|
||||
|
||||
async function requireOwnFamilyChild(
|
||||
@@ -51,7 +114,7 @@ export async function createMyChild(rawPayload: unknown) {
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -117,10 +180,24 @@ export async function updateMyContact(rawPayload: unknown) {
|
||||
const parsed = contactSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Kontaktdaten." };
|
||||
return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") };
|
||||
}
|
||||
|
||||
try {
|
||||
const currentUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.user.id },
|
||||
select: { directoryOptInAt: true },
|
||||
});
|
||||
|
||||
let newDirectoryOptInAt: Date | null = currentUser.directoryOptInAt;
|
||||
if (parsed.data.directoryOptIn) {
|
||||
if (!newDirectoryOptInAt) {
|
||||
newDirectoryOptInAt = new Date();
|
||||
}
|
||||
} else {
|
||||
newDirectoryOptInAt = null;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: {
|
||||
@@ -128,6 +205,7 @@ export async function updateMyContact(rawPayload: unknown) {
|
||||
street: parsed.data.street || null,
|
||||
postalCode: parsed.data.postalCode || null,
|
||||
city: parsed.data.city || null,
|
||||
directoryOptInAt: newDirectoryOptInAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -146,7 +224,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
|
||||
const parsed = childSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -224,6 +302,27 @@ export async function deleteMyAccount() {
|
||||
return { error: "Account wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const activeNotdienstAssignments = user.familyId
|
||||
? await prisma.notdienstAssignment.count({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
date: { gte: today },
|
||||
plan: { status: NotdienstPlanStatus.PUBLISHED },
|
||||
child: { familyId: user.familyId },
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
|
||||
if (activeNotdienstAssignments > 0) {
|
||||
return {
|
||||
error:
|
||||
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.familyId) {
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
|
||||
+272
-167
@@ -1,156 +1,201 @@
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import Link from "next/link";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import {
|
||||
AtSign,
|
||||
Baby,
|
||||
CalendarHeart,
|
||||
Home,
|
||||
Mail,
|
||||
Phone,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DefItem, DefList, EmptyDefValue } from "@/components/ui/def-list";
|
||||
import { RoleBadge } from "@/components/ui/role-badge";
|
||||
import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
||||
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||
import { ContactForm } from "./_components/contact-form";
|
||||
import { StaffEmailSettings } from "../personal/forms";
|
||||
|
||||
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 },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
familyId: true,
|
||||
phone: true,
|
||||
street: true,
|
||||
postalCode: true,
|
||||
city: true,
|
||||
createdAt: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
children: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
const [kita, user] = await Promise.all([
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: session.user.kitaId },
|
||||
select: { name: true },
|
||||
}),
|
||||
prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
familyId: true,
|
||||
phone: true,
|
||||
street: true,
|
||||
postalCode: true,
|
||||
city: true,
|
||||
directoryOptInAt: true,
|
||||
staffDecisionEmails: true,
|
||||
createdAt: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
children: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
dutyAssignments: {
|
||||
select: {
|
||||
id: true,
|
||||
duty: {
|
||||
select: {
|
||||
name: true,
|
||||
dutyAssignments: {
|
||||
select: {
|
||||
id: true,
|
||||
duty: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
const fullName = `${user.firstName} ${user.lastName}`.trim();
|
||||
|
||||
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.
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-9">
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
PROFIL · {kita.name}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Mein{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
Profil
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
{user.role === UserRole.ERZIEHER
|
||||
? "Deine persönlichen Daten, dein Arbeitsbereich und Datenschutzeinstellungen."
|
||||
: "Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen."}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<div className="grid items-start gap-5 lg:grid-cols-2">
|
||||
<Card className="self-stretch">
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<UserCircle className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Persönliche Daten</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Read-only Stammdaten deines Accounts.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</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 flex items-center gap-1">
|
||||
<Phone className="h-3.5 w-3.5" /> Telefon
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{user.phone ?? "Noch nicht hinterlegt"}
|
||||
</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>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Home className="h-3.5 w-3.5" /> Haushalt
|
||||
</div>
|
||||
{user.family ? (
|
||||
<div className="font-medium">{user.family.name}</div>
|
||||
) : (
|
||||
<div className="mt-2 space-y-3 rounded-md border border-dashed p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dein Account ist noch keinem Haushalt zugeordnet. Lege hier
|
||||
deinen eigenen Haushalt an, wenn du selbst Elternteil in der
|
||||
Kita bist.
|
||||
</p>
|
||||
<CreateFamilyDialog
|
||||
defaultFamilyName={`Familie ${user.lastName}`}
|
||||
/>
|
||||
</div>
|
||||
<CardContent>
|
||||
<DefList>
|
||||
<DefItem icon={UserCircle} label="Name" value={fullName} />
|
||||
<DefItem icon={Mail} label="E-Mail" value={user.email} />
|
||||
<DefItem
|
||||
icon={Phone}
|
||||
label="Telefon"
|
||||
value={user.phone ? user.phone : <EmptyDefValue />}
|
||||
/>
|
||||
<DefItem
|
||||
icon={Shield}
|
||||
label="Rolle"
|
||||
value={<RoleBadge role={user.role} />}
|
||||
/>
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<DefItem
|
||||
icon={Home}
|
||||
label="Haushalt"
|
||||
value={
|
||||
user.family ? (
|
||||
user.family.name
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
|
||||
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
|
||||
Dein Account ist noch keinem Haushalt zugeordnet.
|
||||
</p>
|
||||
<CreateFamilyDialog
|
||||
defaultFamilyName={`Familie ${user.lastName}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<DefItem
|
||||
icon={CalendarHeart}
|
||||
label="Mitglied seit"
|
||||
value={format(user.createdAt, "PPP", { locale: de })}
|
||||
/>
|
||||
</DefList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-4">
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Meine Arbeitszeit & Urlaub</CardTitle>
|
||||
<CardDescription>
|
||||
Wochenmodell, Urlaubsanspruch, Zeiterfassung und Überstunden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link
|
||||
href="/dashboard/personal"
|
||||
className="font-medium text-[var(--accent-deep)] underline underline-offset-4"
|
||||
>
|
||||
Personalbereich öffnen →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5" />
|
||||
Kontaktdaten
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Diese Angaben helfen bei Notdienst, Adressbuch und Abstimmung in
|
||||
der Kita.
|
||||
</CardDescription>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<AtSign className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Kontaktdaten</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Für Notdienst, Adressbuch und Abstimmung in der Kita.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ContactForm
|
||||
@@ -159,71 +204,131 @@ export default async function ProfilPage() {
|
||||
street: user.street,
|
||||
postalCode: user.postalCode,
|
||||
city: user.city,
|
||||
directoryOptIn: !!user.directoryOptInAt,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kinder */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Baby className="h-5 w-5" />
|
||||
Meine Kinder
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MyChildrenManager
|
||||
items={(user.family?.children ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth
|
||||
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
canManage={!!user.familyId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>E-Mail-Hinweise</CardTitle>
|
||||
<CardDescription>
|
||||
Benachrichtigungen aus dem Personalbereich.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StaffEmailSettings enabled={user.staffDecisionEmails} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Baby className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Kinder</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Kinder deines Haushalts verwalten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MyChildrenManager
|
||||
items={(user.family?.children ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth
|
||||
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
canManage={!!user.familyId}
|
||||
/>
|
||||
</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>
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Ämter & Dienste</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Feste Aufgaben und laufende Zuständigkeiten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.dutyAssignments.length === 0 ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
|
||||
Keine festen Ämter übernommen
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">
|
||||
Dienste und Rollen erscheinen hier, sobald sie
|
||||
zugewiesen sind.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{user.dutyAssignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||
{assignment.duty.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
laufender Dienst
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-destructive/20 mt-8">
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Hier kannst du dein Profil und je nach Haushaltssituation auch die
|
||||
zugehörigen Familiendaten DSGVO-konform löschen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card className="mt-5 bg-[var(--surface-2)]">
|
||||
<CardContent className="flex items-center justify-between gap-6 pt-[22px] max-[760px]:flex-col max-[760px]:items-start">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-2 text-[var(--danger)]">
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
<h2 className="text-lg font-medium tracking-[-0.02em]">
|
||||
Danger Zone
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-2 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
||||
Hier kannst du dein Profil und je nach Haushaltssituation auch die
|
||||
zugehörigen Familiendaten DSGVO-konform löschen. Aktive
|
||||
Notdienst-Verpflichtungen blockieren die Löschung.
|
||||
</p>
|
||||
</div>
|
||||
<DeleteAccountDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import type { RsvpEventDto } from "@/lib/rsvp";
|
||||
import { RsvpDialog } from "@/components/rsvp-form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export function RsvpActionCard({ events }: { events: RsvpEventDto[] }) {
|
||||
if (events.length === 0) return null;
|
||||
return (
|
||||
<Card className="mb-5 border-[var(--warn)] bg-[var(--warn-soft)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-[var(--warn)]" />
|
||||
Rückmeldung erforderlich
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Für diese Termine fehlt noch eine Antwort eurer Familie.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{event.title}</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Rückmeldung bis{" "}
|
||||
{format(
|
||||
new Date(event.rsvpDeadline ?? event.startDate),
|
||||
"dd.MM.yyyy HH:mm",
|
||||
{ locale: de },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<RsvpDialog event={event} />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
getMarketingFeature,
|
||||
marketingFeatures,
|
||||
} from "@/lib/marketing/features";
|
||||
|
||||
type FeaturePageProps = {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return marketingFeatures.map((feature) => ({
|
||||
slug: feature.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: FeaturePageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const feature = getMarketingFeature(slug);
|
||||
|
||||
if (!feature) {
|
||||
return {
|
||||
title: "Feature nicht gefunden",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: feature.seoTitle,
|
||||
description: feature.seoDescription,
|
||||
openGraph: {
|
||||
title: feature.seoTitle,
|
||||
description: feature.seoDescription,
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Ein Kind spielt mit bunten Holzklötzen.",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: feature.seoTitle,
|
||||
description: feature.seoDescription,
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FeaturePage({ params }: FeaturePageProps) {
|
||||
const { slug } = await params;
|
||||
const feature = getMarketingFeature(slug);
|
||||
|
||||
if (!feature) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const Icon = feature.icon;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
|
||||
<header className="border-b border-slate-200 bg-white/95">
|
||||
<div className="mx-auto flex h-20 w-full max-w-7xl items-center justify-between px-5 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-3 text-slate-950">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-950 text-white">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-semibold tracking-tight">
|
||||
Kita-Planer
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/#kontakt">Demo anfragen</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="bg-slate-950 px-5 py-20 text-white sm:px-6 lg:py-24">
|
||||
<div className="mx-auto grid w-full max-w-7xl gap-12 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
|
||||
<div>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="-ml-3 mb-8 text-slate-300 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Link href="/">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zur Startseite
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Badge className="mb-6 border-white/15 bg-white/10 text-white hover:bg-white/10">
|
||||
{feature.eyebrow}
|
||||
</Badge>
|
||||
<h1 className="max-w-4xl text-balance text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl">
|
||||
{feature.heroTitle}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
|
||||
{feature.heroDescription}
|
||||
</p>
|
||||
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
|
||||
>
|
||||
<Link href="/#kontakt">
|
||||
Demo anfragen
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="h-12 border-white/35 bg-white/10 px-6 text-white hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<Link href="/register">Kita registrieren</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.06] p-6 shadow-2xl shadow-slate-950/30">
|
||||
<div
|
||||
className={`mb-6 flex h-14 w-14 items-center justify-center rounded-lg ring-1 ${feature.accent}`}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-emerald-200">
|
||||
In einem Satz
|
||||
</p>
|
||||
<p className="mt-4 text-2xl font-semibold leading-snug">
|
||||
{feature.description}
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 text-sm text-slate-300">
|
||||
{feature.benefits.slice(0, 3).map((benefit) => (
|
||||
<div key={benefit.title} className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300" />
|
||||
<span>{benefit.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto grid w-full max-w-7xl gap-6 px-5 py-20 sm:px-6 lg:grid-cols-2">
|
||||
<Card className="border-slate-200 bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl tracking-tight">
|
||||
{feature.problemTitle}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base leading-7 text-slate-600">
|
||||
{feature.problemText}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl tracking-tight">
|
||||
{feature.solutionTitle}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base leading-7 text-slate-600">
|
||||
{feature.solutionText}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="px-5 pb-20 sm:px-6">
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Was das konkret bringt
|
||||
</p>
|
||||
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
||||
Weniger Abstimmungslast, mehr Klarheit im Kita-Alltag.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{feature.benefits.map((benefit) => (
|
||||
<Card key={benefit.title} className="border-slate-200 bg-white">
|
||||
<CardHeader>
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-xl tracking-tight">
|
||||
{benefit.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm leading-6 text-slate-600">
|
||||
{benefit.text}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-950 px-5 py-18 text-white sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<Badge className="mb-5 border-emerald-300/20 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/10">
|
||||
Vertrauen & Datenschutz
|
||||
</Badge>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">
|
||||
{feature.trustTitle}
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-7 text-slate-300">
|
||||
{feature.trustText}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90 lg:shrink-0"
|
||||
>
|
||||
<Link href="/datenschutz">Datenschutz ansehen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Fragen aus dem Vorstand
|
||||
</p>
|
||||
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
||||
Kurz beantwortet.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{feature.faqs.map((faq) => (
|
||||
<Card key={faq.question} className="border-slate-200 bg-white">
|
||||
<CardHeader>
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-lg leading-snug">
|
||||
{faq.question}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm leading-6 text-slate-600">
|
||||
{faq.answer}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-5 pb-20 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 rounded-lg border border-slate-200 bg-white p-8 shadow-sm sm:p-10 md:flex-row md:items-center md:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Wollt ihr {feature.shortTitle} im echten Kita-Alltag sehen?
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||
Schreibt uns kurz, wie ihr heute organisiert seid. Wir zeigen
|
||||
euch, wie Kita-Planer zu eurem Elternverein passen kann.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/#kontakt">
|
||||
Demo anfragen
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-slate-200 bg-white">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-8 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<span>© {new Date().getFullYear()} Kita-Planer</span>
|
||||
<nav className="flex gap-5">
|
||||
<Link href="/impressum" className="hover:text-slate-950">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-slate-950">
|
||||
Datenschutz
|
||||
</Link>
|
||||
<Link href="/#kontakt" className="hover:text-slate-950">
|
||||
Kontakt
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user