Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de3e9ae7e | ||
|
|
d4aa90422f | ||
|
|
abe73bb9ac | ||
|
|
1472779781 | ||
|
|
661776e609 | ||
|
|
061024d901 | ||
|
|
99d3a63eed | ||
|
|
00f602a7aa | ||
|
|
e50a8c646b | ||
|
|
e6a30be579 | ||
|
|
8915fe7df6 |
@@ -53,7 +53,7 @@ Grenzen: 10 MiB pro Datei, fünf Dateien und 25 MiB pro Nachricht; standardmäß
|
|||||||
|
|
||||||
`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt.
|
`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt.
|
||||||
|
|
||||||
Die SQL-Migrationen ergänzen **41 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
|
Die SQL-Migrationen ergänzen **45 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
|
||||||
|
|
||||||
## Verifikation und Updates
|
## Verifikation und Updates
|
||||||
|
|
||||||
@@ -68,3 +68,20 @@ npm audit --omit=dev
|
|||||||
Die Integrationstests setzen eine ausdrücklich isolierte lokale PostgreSQL-Datenbank namens `kita_security_test` voraus; `SECURITY_TEST_DATABASE_URL` darf keine Anwendungsdaten enthalten. `npm run test:security` prüft Aktionen und echte DB-Transaktionen. `npm run test:security:http` benötigt den Production-Server auf localhost:3057 mit derselben Testdatenbank, `UPLOAD_DIR` und dem im Test dokumentierten Test-Secret. Niemals dieses Test-Secret in Produktion verwenden.
|
Die Integrationstests setzen eine ausdrücklich isolierte lokale PostgreSQL-Datenbank namens `kita_security_test` voraus; `SECURITY_TEST_DATABASE_URL` darf keine Anwendungsdaten enthalten. `npm run test:security` prüft Aktionen und echte DB-Transaktionen. `npm run test:security:http` benötigt den Production-Server auf localhost:3057 mit derselben Testdatenbank, `UPLOAD_DIR` und dem im Test dokumentierten Test-Secret. Niemals dieses Test-Secret in Produktion verwenden.
|
||||||
|
|
||||||
Docker-Images sind auf Digests fixiert. Neue Sicherheitsreleases regelmäßig prüfen und die Digests bewusst aktualisieren. Zum Prüfzeitpunkt verbleibt eine ungepatchte `braces`-Schwachstelle in der Entwicklungs-Lint-Kette; `npm audit --force` löst sie nicht sinnvoll. Der Runtime-Audit ist ohne bekannte Findings. NextAuth bleibt eine Beta-Abhängigkeit und benötigt weiterhin Update-Beobachtung.
|
Docker-Images sind auf Digests fixiert. Neue Sicherheitsreleases regelmäßig prüfen und die Digests bewusst aktualisieren. Zum Prüfzeitpunkt verbleibt eine ungepatchte `braces`-Schwachstelle in der Entwicklungs-Lint-Kette; `npm audit --force` löst sie nicht sinnvoll. Der Runtime-Audit ist ohne bekannte Findings. NextAuth bleibt eine Beta-Abhängigkeit und benötigt weiterhin Update-Beobachtung.
|
||||||
|
|
||||||
|
## Familieneinladungen und Abwesenheiten
|
||||||
|
|
||||||
|
Die Migration `20261010000000_family_invitation_flows` ergänzt den Versandstatus und die mandantengebundene Empfängerreferenz. Der bestehende Migrationsdienst spielt sie vor dem App-Start ein; kein `prisma db push` und kein erneutes Löschen erforderlich. Fehlgeschlagene Familieneinladungen können in der Familienübersicht erneut versendet werden. Fachliche Abläufe: [Familien und Abwesenheiten](../docs/FAMILIEN_UND_ABWESENHEITEN.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Notdienst-Erinnerungen und Veröffentlichungsmails
|
||||||
|
|
||||||
|
Die Migration `20261012000000_notdienst_notifications` ergänzt Mailaufträge und die monatliche Erinnerungsrunde. Vor dem Start der neuen App `npm run db:migrate:deploy` ausführen und die Runtime-Grants erneut bereitstellen. Der bestehende Initialisierungsdienst erledigt beide Schritte im Compose-Deployment. Kein `db push` verwenden; die bisherigen Tenant-Constraints bleiben erhalten.
|
||||||
|
|
||||||
|
`NOTDIENST_JOB_SECRET` (mindestens 32 Zeichen), `AUTH_URL`/`NEXT_PUBLIC_SITE_URL`, `RESEND_API_KEY` und `EMAIL_FROM` müssen eingerichtet sein. Ein neues Secret oder ein zusätzlicher externer Cronjob ist nicht nötig, wenn der vorhandene Notdienstjob bereits konfiguriert ist. Der Produktionsstart überwacht Webserver und Worker gemeinsam: der Worker wird nach einem Abbruch neu gestartet; beim Ende des Webservers werden beide Prozesse beendet.
|
||||||
|
|
||||||
|
Der geschützte `POST /api/jobs/notdienstalarme` verarbeitet weiterhin Alarme und zusätzlich Planungserinnerungen/gespeicherte Benachrichtigungen. Die JSON-Antwort ergänzt `planning` mit `queued`, `sent`, `failed` und `skipped`. Ein Lauf bearbeitet bis zu 100 fällige Kitas und zwölf Planungsmails (vier parallel). Der Worker ruft die Route minütlich auf. Versanddetails und manuelle Wiederholung finden sich in der Monatsplanung.
|
||||||
|
|
||||||
|
Die automatische Runde läuft einmal pro Kita und Folgemonat, ab 9 Uhr Berliner Zeit, standardmäßig ab dem 19. (sieben Tage vor Ende der Eintragsfrist am 26.). Ein verpasster Termin wird bis zum 26. nachgeholt. Die Einstellung 0 schaltet diese Runde ab; ein bereits veröffentlichter Plan beendet Verfügbarkeitserinnerungen. Manuelle Erinnerungen sind für den offenen Folgemonat möglich und je Konto/Tag mit der automatischen Runde zusammen dedupliziert.
|
||||||
|
|
||||||
|
Veröffentlichung und Mailvorbereitung sind eine Transaktion. Anschließend wird der Versand versucht; ein Mailfehler setzt den Plan nicht zurück. Wiederholungen prüfen aktuelle Konten, Haushalt, aktive Kinder und Einteilungen erneut. Pro Konto wird eine Mail mit allen aktuellen eigenen Diensten vorbereitet. Versandaufträge erhalten einen stabilen Idempotenzschlüssel je Nachrichteninhalt und eine fünfminütige Reservierung; Fehler erhalten zunehmende Wartezeiten und bleiben sichtbar. Der Mailtransport bricht HTTP-Anfragen nach zehn Sekunden ab. Anbieterannahme, wiederholter Versand nach unbekanntem Transportausgang und tatsächliche Postfachzustellung sind weiterhin durch die Garantien des Mailanbieters begrenzt.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
|
|||||||
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
|
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
|
||||||
ADMIN_EMAIL="[kontakt@deine-domain.de]"
|
ADMIN_EMAIL="[kontakt@deine-domain.de]"
|
||||||
|
|
||||||
# Automatische Notdienst-Fristpruefung im App-Container.
|
# Automatische Notdienst-Fristpruefung, Verfuegbarkeitserinnerungen und Mail-Wiederholungen.
|
||||||
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
|
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
|
||||||
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Historical upgrade helper, retained for compatibility tests only.
|
||||||
|
-- Production uses prisma/migrations/20261010000000_family_invitation_flows.
|
||||||
|
-- Additive upgrade before Prisma db push; safe to run repeatedly.
|
||||||
|
-- On a fresh database Prisma creates the entire schema afterwards.
|
||||||
|
DO $upgrade$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('invitations') IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ALTER TABLE "invitations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "userId" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "sentAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN IF NOT EXISTS "lastAttemptAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN IF NOT EXISTS "leaseUntil" TIMESTAMP(3),
|
||||||
|
ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS "failed" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "invitations_userId_key" ON "invitations"("userId");
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'invitations_userId_fkey'
|
||||||
|
AND conrelid = 'invitations'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "invitations"
|
||||||
|
ADD CONSTRAINT "invitations_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$upgrade$;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||||
|
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||||
-- Additives Upgrade für einen Personalbereich einschließlich vorhandener Mail-Erweiterung.
|
-- 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.
|
-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||||
|
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||||
-- Nur E-Mail-Erweiterung für einen bereits mit 043fed2 eingeführten Personalbereich.
|
-- 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.
|
-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||||
|
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||||
-- Einmalige Einführung des vollständigen Personalbereichs inklusive E-Mails ab ec3f4f9.
|
-- Einmalige Einführung des vollständigen Personalbereichs inklusive E-Mails ab ec3f4f9.
|
||||||
-- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
|
-- Beschreibt ausschließlich den damaligen Ausgangsstand vor Einführung der Migrationshistorie.
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Prüfung der Bedienabläufe im Kita-Planer
|
||||||
|
|
||||||
|
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt).
|
||||||
|
|
||||||
|
Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). F06 (Agenda-Details und Mitbringen), F09 (Arbeitszeitkontext und Ein-Tages-Anträge), F10 (personenbezogene Ämter-Schaltflächen) und F11 (Passwort-Wiederherstellung/-Änderung) sind ebenfalls umgesetzt; F08 enthält nun kontoabhängiges Ausblenden des Einrichtungshinweises. Die weiteren Teile von F08 und F11 bleiben durch diese Ergänzung unberührt. F07 (Mitteilungskorrektur, Verwaltung älterer Beiträge und getrennte Versandwiederholung) ist mit der Ergänzung vom 13.10.2026 ebenfalls umgesetzt. Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand.
|
||||||
|
|
||||||
|
## Umfang und Methode
|
||||||
|
|
||||||
|
Geprüft wurden die zentralen Abläufe für Eltern, Fachkräfte, Kita-Admins und Notdienst-Koordination: Einrichtung, Familien, Profile, Einladungen, Abwesenheiten, Arbeitszeit und Urlaub, Kalender und Anmeldungen, Mitteilungen, Dienste, Notdienstplanung und Alarmierung. Login, Kontoverwaltung und die Superadmin-Oberflächen wurden ergänzend im Quellcode geprüft.
|
||||||
|
|
||||||
|
Grundlage sind die aktuellen Seiten, Formulare, Server-Aktionen und Datenmodelle. Einzelne Abläufe wurden zusätzlich in einer lokalen Vorschau mit synthetischen Testkonten nachvollzogen: Familienverwaltung, Eltern-Abwesenheiten sowie Kalender in Agenda- und Listenansicht. Dabei wurden keine fachlichen Datensätze angelegt, geändert oder gelöscht und keine E-Mails verschickt. Die produktive Website wurde nicht getestet.
|
||||||
|
|
||||||
|
Die folgenden Befunde beschreiben den bestehenden Stand. Die vorgeschlagenen Zielabläufe sind noch nicht umgesetzt. Diese Prüfung ist kein vollständiger Sicherheitstest oder Test aller Berechnungen. Die ältere `FUNKTIONSUEBERSICHT.md` wurde nicht als Beleg für fehlende Funktionen verwendet: Kalender-Anmeldungen, Mitteilungen, Personalverwaltung und Notdienstalarm sind inzwischen vorhanden.
|
||||||
|
|
||||||
|
## Bereits mit dem Nutzer geklärt
|
||||||
|
|
||||||
|
- Eltern dürfen eigene Abwesenheitsmeldungen **für zukünftige Tage** ändern und zurücknehmen. Für laufende Tage ist die Kita zuständig.
|
||||||
|
- **Kita-Admins dürfen innerhalb ihrer eigenen Kita weitere Admins und die Notdienst-Koordination ernennen.**
|
||||||
|
|
||||||
|
Diese Regeln sind für die spätere Umsetzung verbindlich. Die Datumsregel muss auch auf dem Server gelten. Für Meldungen, die einen laufenden und zukünftige Tage umfassen, ist die Behandlung des zukünftigen Teilzeitraums noch zu konkretisieren.
|
||||||
|
|
||||||
|
## Prioritäten
|
||||||
|
|
||||||
|
„Hoch“ bezeichnet einen blockierten Ablauf, widersprüchliche Rückmeldungen oder fachlich irreführende Angaben. „Mittel“ bezeichnet vermeidbare Nacharbeit oder einen fehlenden direkten Weg. „Niedrig“ bezeichnet kleinere Verbesserungen bei Benennung und Orientierung.
|
||||||
|
|
||||||
|
| ID | Priorität | Bereich | Wichtigster Befund |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| F01 | Hoch | Familieneinladungen | Gespeicherte Familie erscheint bei Mailfehler als fehlgeschlagener Vorgang; erneutes Senden fehlt. |
|
||||||
|
| F02 | Hoch | Kinder-Abwesenheiten | Elternlink führt zu „Zugriff verweigert“; Ändern und Zurücknehmen fehlen. |
|
||||||
|
| F03 | Hoch | Notdienst-Erinnerungen | Oberfläche verspricht Automatik, die Einstellung wird dafür nicht verwendet; unvollständige Rückmeldungen werden übergangen. |
|
||||||
|
| F04 | Hoch | Rollenverwaltung | Weitere Kita-Admins und Koordination lassen sich nicht regulär ernennen. |
|
||||||
|
| F05 | Mittel | Notdienstplan | Manuelle Einteilung zeigt Verfügbarkeit nicht am Auswahlpunkt; Erstveröffentlichung verschickt keine Nachricht. |
|
||||||
|
| F06 | Mittel | Kalender und Mitbringen | Funktionen hängen von der Ansicht ab; Mitbring-Einträge lassen sich nur löschen und neu anlegen. |
|
||||||
|
| F07 | Mittel | Mitteilungen | Korrektur und gezieltes erneutes Senden fehlen. |
|
||||||
|
| F08 | Mittel | Eigene Ersteinrichtung | Haushalt, Kinder und Kontakt werden separat gespeichert; Admins ohne eigene Kinder gelten als unvollständig. |
|
||||||
|
| F09 | Mittel | Personal | Neue Zeiterfassung verliert den betrachteten Zeitraum; Ein-Tages-Anträge verlangen zweimal das Datum. |
|
||||||
|
| F10 | Niedrig | Ämter und Dienste | Gleich benannte Ämter-Schaltflächen lassen die betroffene Person erst nach dem Öffnen erkennen. |
|
||||||
|
| F11 | Mittel | Zugang und Wiederherstellung | Passwort-Wiederherstellung fehlt; alte Aktivierungslinks und seltene Alt-Konten enden in Sackgassen. |
|
||||||
|
| F12 | Hoch / Niedrig | Filter und Anzeigen | Abwesenheitsgruppen sind aus Namen erfunden; die Notdienst-Zahl im Menü ist fest eingetragen. |
|
||||||
|
|
||||||
|
## F01 – Familieneinladungen sauber abschließen und erneut senden
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Admin legt eine Familie samt Eltern und Kindern an. Die Datenbank speichert die Familie, Konten und Aktivierungslinks. Anschließend werden die Einladungsmails versendet. Scheitert eine Mail, meldet die Aktion einen Formularfehler; der erfolgreiche Abschluss und die Aktualisierung der Ansicht bleiben aus. Ein erneuter Speicherversuch kann dann an den bereits angelegten E-Mail-Adressen scheitern. Bei mehreren Einladungen stoppt der Versand bereits beim ersten Fehler.
|
||||||
|
|
||||||
|
Die Familienliste zeigt offene Einladungen, bietet aber weder dort noch in „Details“ eine Funktion zum erneuten Senden. Das wurde im lokalen Browser bestätigt. Auch der Hinweis eines abgelaufenen Aktivierungslinks, beim Admin einen neuen Link anzufordern, hat damit keinen passenden Bedienweg.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** Familie einmal anlegen → Erfolg anzeigen und Liste aktualisieren → Versandstatus je Elternteil anzeigen → bei Bedarf ausschließlich die Einladung erneut senden. Der Datensatz bleibt unabhängig vom Mailversand erfolgreich angelegt. Fehlgeschlagene Mails werden gespeichert und gezielt wiederholt; weitere Empfänger werden trotz eines einzelnen Fehlers bearbeitet.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/families/actions.ts:125`, `:277`, `:459`; `src/app/dashboard/families/add-family-dialog.tsx`; `src/app/dashboard/families/_components/families-list-client.tsx`; `src/app/dashboard/families/edit-family-dialog.tsx`.
|
||||||
|
|
||||||
|
**Abnahme:** Simulierter Mailfehler führt zu genau einer Familie, sichtbarem Versandfehler und einem funktionierenden erneuten Senden ohne erneute Personeneingabe. Der kürzlich verbesserte Fachkräfte-Ablauf dient als Vorlage.
|
||||||
|
|
||||||
|
## F02 – Eigene Abwesenheiten direkt verwalten
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Eltern melden ein Kind ab und sehen die Meldung auf der Übersicht. Dort fehlen Änderungs- und Rücknahmeaktionen. „Alle Abwesenheiten“ führt auf eine Seite, die nur Admins, Koordination und Fachkräfte betreten dürfen. Der Elternaccount erreicht im lokalen Browser „Zugriff verweigert“.
|
||||||
|
|
||||||
|
Die vorhandene Server-Aktion `deleteAbsence` wird von keiner Oberfläche verwendet. Sie prüft die Haushaltszugehörigkeit, aber bisher keine Beschränkung auf zukünftige Tage. Eine Aktion zum Ändern fehlt. Die Kita-Übersicht bietet ebenfalls nur die Anzeige, keinen direkten Korrekturweg für laufende Meldungen.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** „Meine Abwesenheiten“ → Meldung ansehen → zukünftige Tage bearbeiten oder zurücknehmen. Für laufende Tage führt ein verständlicher Hinweis zur Kita; berechtigte Kita-Nutzer können die Meldung in ihrer Übersicht korrigieren. Eltern bekommen eine eigene Haushaltsansicht. Die kitaweite Übersicht behält ihre bestehenden Zugriffsgrenzen.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/absence-card.tsx:234`, `:241`; `src/app/dashboard/admin/abwesenheiten/page.tsx:16`; `src/actions/absences.ts:107`; `src/app/dashboard/admin/abwesenheiten/absence-table.tsx`.
|
||||||
|
|
||||||
|
**Abnahme:** Eltern erreichen ihre Meldungen ohne Zugriffsfehler. Änderungen gelten nur für den eigenen Haushalt und die erlaubten Tage; Kita-Korrekturen sind innerhalb derselben Kita möglich. Für die Datumsgrenze gilt die lokale Kita-Zeitzone.
|
||||||
|
|
||||||
|
## F03 – Notdienst-Einstellungen und Erinnerungen verständlich machen
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** „Mindesteinteilungen pro Kind/Monat“ wird als Richtmaß der automatischen Verteilung erklärt. Tatsächlich bestimmt der Wert die erforderliche Anzahl gemeldeter Verfügbarkeiten. „Erinnerungsfrist“ verspricht automatische E-Mails; im geprüften Code wird die Einstellung gespeichert und angezeigt, aber von keinem Erinnerungsjob ausgewertet. Die Eingabesperre folgt stattdessen dem fest codierten Stichtag 26. Der vorhandene Notdienstalarm-Job betrifft Alarm-Erinnerungen und ersetzt diese Verfügbarkeits-Erinnerung nicht.
|
||||||
|
|
||||||
|
Die manuelle Erinnerungsaktion behandelt eine Familie schon nach einem einzigen Verfügbarkeitseintrag als beantwortet. Familien unterhalb ihrer geforderten Anzahl werden dadurch nicht angeschrieben. Einzelne Versandfehler landen im Log; die Oberfläche zeigt nur die Anzahl erfolgreicher Mails. Erneutes Auslösen wiederholt auch bereits erfolgreich versendete Mails.
|
||||||
|
|
||||||
|
Zusätzlich kann die Planung einen beliebigen Monat anzeigen. Der Link in einer Erinnerung führt dagegen ohne Monatsangabe zur Elternseite, die den nächsten Monat öffnet. Erinnerung und Eingabe können dadurch unterschiedliche Monate betreffen.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** „Erforderliche Verfügbarkeiten“ und tatsächlichen Abgabeschluss klar benennen → Rücklauf als „fehlt / unvollständig / vollständig“ zeigen → Empfänger vor dem Versand sichtbar machen → Versandstatus und gezieltes Wiederholen anbieten. Eine automatische Erinnerung benötigt einen echten Job und eine passende Einstellung. Erinnerungen müssen den betroffenen Monat öffnen oder auf den tatsächlich bearbeitbaren Monat beschränkt sein.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/notdienst/plan/_components/settings-modal.tsx:76`, `:95`; `src/app/dashboard/notdienst/page.tsx:52`; `src/lib/date-utils.ts:14`; `src/app/dashboard/notdienst/plan/actions.ts:426`, `:485`, `:503`, `:532`.
|
||||||
|
|
||||||
|
**Abnahme:** Texte entsprechen der tatsächlichen Berechnung und Automatik. Auch teilweise ausgefüllte Haushalte werden berücksichtigt. Der E-Mail-Link führt zur richtigen Eingabe; fehlgeschlagene Zustellungen können einzeln wiederholt werden.
|
||||||
|
|
||||||
|
## F04 – Rollen dort verwalten, wo Personen verwaltet werden
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Registrierung und Kita-Einrichtung erzeugen den ersten Admin. Familienanlage erzeugt Elternkonten; die Fachkräfte-Anlage erzeugt Fachkraftkonten. Es gibt keinen regulären Weg, einen weiteren Kita-Admin oder eine Notdienst-Koordination zu ernennen. Auch die Superadmin-Aktionen bieten keine Rollenänderung.
|
||||||
|
|
||||||
|
Ein festes Amt wie „Vorstand“ ist eine gesonderte Zuordnung und verleiht keine Admin-Rechte. Diese Trennung ist in der Bedienung nicht ausreichend erkennbar.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf, vom Nutzer bestätigt:** Kita-Admin öffnet eine Person innerhalb seiner Kita → wählt deren Zugriffsrolle → speichert die Änderung. Zuständigkeiten/Ämter und Zugriffsrechte erhalten klar getrennte Bezeichnungen. Mindestens ein erreichbarer Kita-Admin muss bestehen bleiben. Dies muss auch bei der eigenen Kontolöschung berücksichtigt werden: `deleteMyAccount` prüft derzeit Notdienst-Verpflichtungen, aber keine letzte Admin-Rolle.
|
||||||
|
|
||||||
|
**Belege:** `src/app/register/actions.ts:91`; `src/app/onboarding/actions.ts:130`; `src/app/dashboard/families/actions.ts:236`, `:404`; `src/app/admin/actions.ts`; `src/app/dashboard/families/duty-manager.tsx`; `src/app/dashboard/profil/actions.ts:286`.
|
||||||
|
|
||||||
|
**Abnahme:** Rollenänderungen wirken innerhalb der eigenen Kita und sind nachvollziehbar. Ein Amt allein verändert keine Berechtigungen. Bestehende Fachkraftprofile und Haushaltszuordnungen werden bei einer Rollenänderung ausdrücklich berücksichtigt.
|
||||||
|
|
||||||
|
## F05 – Manuelle Notdienstplanung ohne Gegenprüfung in einer zweiten Ansicht
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Bei der manuellen Einteilung enthält die Auswahl sämtliche aktiven Kinder samt Familie und bisheriger Einteilungszahl. Ob die Familie am gewählten Tag verfügbar ist, steht nicht in dieser Auswahl. Die Koordination muss die Verfügbarkeit in einer anderen Ansicht prüfen. Geschwister erscheinen als separate Auswahlmöglichkeiten für denselben Haushalt.
|
||||||
|
|
||||||
|
Beim erstmaligen Veröffentlichen wird der Plan freigegeben, aber keine E-Mail versendet. Spätere Änderungen können dagegen Benachrichtigungen auslösen. Das ist eine uneinheitliche Kommunikation; eine Mail zur Erstveröffentlichung ist ein Verbesserungsvorschlag, bisher keine bestätigte Produktregel.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** Tag auswählen → verfügbare Haushalte direkt erkennen → Einteilung vornehmen. Andere Haushalte bleiben für bewusst begründete Sonderfälle auswählbar und sind gekennzeichnet. Veröffentlichung bietet einen klar bezeichneten Schritt zur Information der Familien mit sichtbarem Versandstatus.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/notdienst/plan/page.tsx:118`; `src/app/dashboard/notdienst/plan/plan-view.tsx:643`; `src/app/dashboard/notdienst/plan/actions.ts:139`, `:353`.
|
||||||
|
|
||||||
|
Der Übergang vom Plan zum Notdienstalarm ist dagegen bereits sinnvoll: Der Link übergibt die konkrete Einteilung, sodass diese im Alarmformular nicht erneut gesucht werden muss.
|
||||||
|
|
||||||
|
## F06 – Kalenderfunktionen in jeder Ansicht erreichbar machen
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Die Listenansicht bietet „Details“ einschließlich Mitbring-Liste. Die Monatsansicht bietet diese Inhalte über den Tagesdialog. Die Agenda zeigt zwar Termine und RSVP-Antworten, bietet aber keinen entsprechenden Detailzugang und keine Mitbring-Liste. Eltern müssen die Ansicht wechseln, um denselben Termin vollständig zu bearbeiten. Der Unterschied wurde im lokalen Browser bestätigt.
|
||||||
|
|
||||||
|
Mitbring-Einträge besitzen nur Hinzufügen und Löschen. Eine Mengenänderung oder ein Tippfehler erfordert Löschen und erneute Eingabe.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** Einheitlicher Termindialog aus Monat, Agenda und Liste → Beschreibung, Anmeldung und Mitbringen am selben Ort. Eigene Mitbring-Einträge lassen sich direkt bearbeiten. Bestehende Berechtigungen für fremde Einträge bleiben maßgeblich. Aktueller Zeitraum und Filter bleiben beim Öffnen und Schließen erhalten.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/kalender/_components/calendar-overview-client.tsx:329`, `:856`; `src/app/dashboard/kalender/_components/termin-list.tsx:326`, `:429`; `src/app/dashboard/kalender/_components/mitbringsel-list.tsx:32`, `:46`; `src/app/dashboard/kalender/actions.ts:1048`, `:1089`.
|
||||||
|
|
||||||
|
Die Anmeldung pro Familie oder Kind ist bereits vorhanden und integriert. Sie muss dafür nicht neu gebaut werden.
|
||||||
|
|
||||||
|
## F07 – Mitteilungen korrigieren und Versand unabhängig wiederholen
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Admins können eine Mitteilung erstellen und optional per E-Mail versenden. Nach der Veröffentlichung gibt es keine Bearbeitungsfunktion und keinen erneuten Versand. Die Admin-Seite zeigt nur die letzten acht Mitteilungen, ohne weiterführende Verwaltungsansicht. Eine Korrektur führt deshalb praktisch zu einem weiteren Beitrag; ein Mailfehler hat keinen Wiederholungsweg.
|
||||||
|
|
||||||
|
Positiv: Die Aktion behandelt einen Mailfehler bereits als Warnung zu einer erfolgreich gespeicherten Mitteilung. Die Familienanlage sollte denselben verständlichen Abschluss bieten.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** Veröffentlichte Mitteilung öffnen → Inhalt korrigieren und Änderungsstand sichtbar machen → bei Bedarf über die Korrektur informieren. Für den Versand gibt es einen eigenen Status und eine gezielte Wiederholung. Ältere Mitteilungen bleiben für Admins verwaltbar.
|
||||||
|
|
||||||
|
**Belege:** `src/actions/announcements.ts:80`, `:172`, `:203`, `:211`; `src/app/dashboard/admin/news/page.tsx:50`, `:116`.
|
||||||
|
|
||||||
|
## F08 – Die eigene Ersteinrichtung zusammenführen und rollenabhängig gestalten
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Wer noch keinen eigenen Haushalt hat, legt zuerst dessen Namen an, schließt den Dialog, legt Kinder in einem anderen Dialog an und ergänzt Kontaktdaten in einem weiteren Formular. Der Einrichtungshinweis verlangt einen Haushalt samt Kindern für alle Rollen außer Fachkräften – also auch für Admins oder Koordination ohne eigene Kita-Kinder.
|
||||||
|
|
||||||
|
Dieser Befund betrifft die eigene Ersteinrichtung. Eltern, die über die Familienverwaltung eingeladen wurden, haben ihren Haushalt bereits; dort werden Eltern und Kinder zusammen erfasst.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** Eine geführte Einrichtung mit bekannten Angaben, Kontakten und – wenn relevant – Haushalt samt Kindern. Nach jedem Schritt ist der nächste direkt erreichbar. Admins und Koordination können angeben, ob sie auch als Eltern teilnehmen; fehlende eigene Kinder dürfen sonst keinen dauerhaften Unvollständigkeitshinweis erzeugen. Diese Rollenvariante ist noch fachlich zu bestätigen.
|
||||||
|
|
||||||
|
Kleine Zusatzkorrektur: „Hinweis dauerhaft ausblenden“ wird heute über einen browserweiten Schlüssel gespeichert. Die Einstellung sollte dem jeweiligen Nutzer zugeordnet sein, damit ein anderes Konto am selben Gerät seine eigenen Hinweise behält.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/profil/_components/create-family-dialog.tsx:29`, `:57`; `src/app/dashboard/profil/page.tsx`; `src/components/OnboardingDialog.tsx:46`, `:55`.
|
||||||
|
|
||||||
|
## F09 – Personalformulare mit dem betrachteten Tag verbinden
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** „Arbeitszeit erfassen“ erhält nur das Personalprofil. Ein neuer Eintrag startet immer mit dem heutigen Datum, 08:00–16:00 Uhr und 30 Minuten Pause – auch wenn gerade eine andere Woche betrachtet wird. Nach dem Speichern schließt sich das Formular. Für weitere Arbeitsblöcke muss die Fachkraft es erneut öffnen und den Tag wieder wählen.
|
||||||
|
|
||||||
|
Freizeitausgleich und halber Urlaubstag verlangen laut Formular „Von = Bis“, zeigen aber zwei unabhängige Datumsfelder. Ein einzelner Tag muss damit zweimal angegeben werden.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** „Zeit erfassen“ am konkreten Tag mit vorausgewähltem Datum. „Speichern und weiteren Block erfassen“ behält den Tag bei. Bei Ein-Tages-Anträgen genügt ein Datum; das Ende wird daraus abgeleitet. Tatsächliche Arbeitszeiten bleiben ausdrücklich eingegebene Werte, auch bei festen Arbeitstagen.
|
||||||
|
|
||||||
|
Für die erstmalige Personaleinrichtung wäre ergänzend ein geführter Abschluss für Arbeitsmodell, Urlaubskonto und Anfangssaldo hilfreich. Die getrennten Einstellungen sind für spätere Änderungen sinnvoll; die bisher vereinbarte Anlage mit anschließender Profileinrichtung ist daher kein Fehler.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/personal/page.tsx:287`; `src/app/dashboard/personal/forms.tsx:368`, `:375`, `:477`, `:498`, `:643`.
|
||||||
|
|
||||||
|
## F10 – Ämter-Schaltflächen eindeutig einer Person zuordnen
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Eine Familie mit zwei Elternkonten bekommt zwei nahezu gleich beschriftete „Ämter“-Schaltflächen. Erst nach dem Öffnen zeigt der Dialog, welches Elternteil gemeint ist. Das wurde im lokalen Browser bestätigt. Ein neues Amt anzulegen und es der gerade geöffneten Person zuzuweisen sind außerdem separate Aktionen.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** „Ämter: Vorname“ oder eine gemeinsame Familienansicht mit klar getrennten Personen. Im Personenkontext kann „Amt anlegen und zuweisen“ beide Schritte verbinden.
|
||||||
|
|
||||||
|
Die festen persönlichen Ämter und die wechselnden Elterndienste sind fachlich verschiedene Dinge. Verständliche Bezeichnungen sollten diese Unterscheidung sichtbar machen.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/families/_components/families-list-client.tsx:292`; `src/app/dashboard/families/duty-manager.tsx:107`, `:123`; `prisma/schema.prisma` (`ParentDuty`, `DutyType`).
|
||||||
|
|
||||||
|
## F11 – Zugang wiederherstellen statt außerhalb der Anwendung lösen
|
||||||
|
|
||||||
|
**Aktueller Ablauf:** Im Login fehlt „Passwort vergessen“, im Profil die eigene Passwortänderung. Ein bereits verwendeter Aktivierungslink kann nach dem Löschen des Tokens eine allgemeine 404-Seite liefern. Abgelaufene Links verweisen auf den Admin; bei Familien fehlt dessen erneuter Versandweg aus F01.
|
||||||
|
|
||||||
|
Ein seltener Alt-Daten-Fall führt zusätzlich auf „Datenschutz-Einwilligung erforderlich“, bietet dort aber nur einen Verweis an Admin oder Support. Die Seite hat keinen direkten Abschluss; auch die bestehenden Support-Aktionen bieten dafür keinen passenden Korrekturablauf. Der normale Registrierungs- und Einladungsablauf erfasst die Einwilligung bereits.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** Passwort-Wiederherstellung über einen zeitlich begrenzten Link; eindeutige Weiterleitung zum Login nach bereits erfolgter Aktivierung; passende Möglichkeit für einen neuen Einladungslink. Alt-Konten können eine fehlende Einwilligung innerhalb der Anwendung nachholen.
|
||||||
|
|
||||||
|
**Belege:** `src/app/login/login-form.tsx`; `src/app/dashboard/profil/page.tsx`; `src/app/invite/[token]/page.tsx:39`, `:84`; `src/app/invite/[token]/actions.ts:153`; `src/app/onboarding/consent/page.tsx:46`; `src/app/admin/actions.ts`.
|
||||||
|
|
||||||
|
## F12 – Fachliche Filter und Statuszahlen verlässlich anzeigen
|
||||||
|
|
||||||
|
**Hoch:** Der Gruppenfilter in der Kita-Abwesenheitsübersicht ordnet Kinder über den Zeichencode des ersten Buchstabens ihres Namens „Krippe“ oder „Elementar“ zu. Das ist keine tatsächliche Gruppenzuordnung. Der Filter kann Kinder aus einer vermeintlich fachlichen Übersicht entfernen und dadurch falsche Schlüsse nahelegen.
|
||||||
|
|
||||||
|
**Niedrig:** Das Menü zeigt beim Notdienst fest die Zahl „4“. Sie wird nicht aus offenen Aufgaben berechnet und verändert sich durch deren Erledigung nicht.
|
||||||
|
|
||||||
|
**Vorgeschlagener Zielablauf:** Gruppenfilter erst mit einer echten gespeicherten Gruppenzuordnung anbieten; bis dahin ausblenden. Statuszahlen aus den jeweiligen offenen Aufgaben berechnen und ihre Bedeutung benennen oder weglassen.
|
||||||
|
|
||||||
|
**Belege:** `src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx:69`, `:77`; `src/components/dashboard/sidebar-nav.tsx:60`.
|
||||||
|
|
||||||
|
## Bereits sinnvolle Abläufe und Prüfungsabdeckung
|
||||||
|
|
||||||
|
| Bereich | Ergebnis der Ablaufprüfung |
|
||||||
|
| --- | --- |
|
||||||
|
| Fachkraft anlegen und aktivieren | Name, Pflicht-E-Mail und Starttermin gemeinsam; Profilübergang sowie Einladungsstatus und erneutes Senden vorhanden. |
|
||||||
|
| Familie anlegen | Eltern und Kinder gemeinsam erfassbar; hauptsächlich der Abschluss bei Mailproblemen und der erneute Versand brauchen Verbesserung (F01). |
|
||||||
|
| Profil und Kinderverwaltung | Eigene Kinder und Kontaktdaten bearbeitbar; Ersteinrichtung und Wiederherstellung siehe F08/F11. |
|
||||||
|
| Adressbuch | Suchen, Filtern und Kontakt öffnen am selben Ort; Freigabe im Profil ist eine nachvollziehbare gesonderte Einstellung. |
|
||||||
|
| Kinder-Abwesenheiten | Meldung direkt vom Dashboard möglich; Verwaltung und Gruppenfilter siehe F02/F12. |
|
||||||
|
| Arbeitszeit und Urlaub | Zeiten, Wochenfreigaben, Anträge, feste Arbeitsmodelle und Konten integriert; Eingabekontext siehe F09. |
|
||||||
|
| Feiertage im Personalbereich | Eintrag im selben Personalbereich; nicht automatisch mit Kita-Schließtagen gleichsetzen, da Schließtage Arbeitstage sein können. |
|
||||||
|
| Kalender und RSVP | Anmeldung pro Familie/Kind sowie Terminverwaltung vorhanden; Detailzugang und Mitbringen siehe F06. |
|
||||||
|
| Terminanfragen und Notdiensttausch | Anfrage und Antwortaktionen vorhanden; Freigabe bzw. Zustimmung anderer Personen ist ein notwendiger fachlicher Schritt. |
|
||||||
|
| Mitteilungen | Dashboard-Anzeige und eigenes Archiv vorhanden; Korrektur und Versandverwaltung siehe F07. |
|
||||||
|
| Wechselnde Elterndienste | Dienstarten und Einteilung auf derselben Verwaltungsseite; fachliche Unterscheidung zu persönlichen Ämtern siehe F10. |
|
||||||
|
| Notdienstplanung | Automatischer Vorschlag, Vorschau, manuelle Einteilung und Veröffentlichung vorhanden; F03/F05 betreffen Orientierung und Kommunikation. |
|
||||||
|
| Notdienstalarm | Einteilung aus dem Plan vorausgewählt; Rückmeldung, Frist und Mailstatus integriert. Separater Alarmdialog ist fachlich sinnvoll. |
|
||||||
|
| Kita-Einrichtung, Login und Superadmin | Bestehende Einrichtung, Diagnose und Support geprüft; Zugriffsrollen und Wiederherstellung siehe F04/F11. Keine produktiven Support-Aktionen ausgeführt. |
|
||||||
|
|
||||||
|
„Vorhanden“ bedeutet hier, dass der Bedienweg im aktuellen Code nachvollziehbar ist. Es ist keine Zusicherung, dass jede Ausnahme oder Berechnung bereits durch diese Prüfung getestet wurde.
|
||||||
|
|
||||||
|
## Empfohlene Umsetzungspakete
|
||||||
|
|
||||||
|
1. **Sackgassen und falsche Anzeigen beseitigen:** Eltern-Abwesenheitslink und eigene Verwaltungsansicht (F02), erfundenen Gruppenfilter entfernen (F12), gespeicherte Familien bei Mailfehler korrekt abschließen und Einladungen erneut senden (F01).
|
||||||
|
2. **Notdienst-Rücklauf verlässlich machen:** Einstellungen richtig benennen, unvollständige Meldungen erkennen, Monatsbezug und Versandstatus korrigieren (F03); anschließend manuelle Einteilung und Veröffentlichung vereinfachen (F05).
|
||||||
|
3. **Personen und Zugänge verwalten:** Kita-interne Rollenverwaltung gemäß Nutzerentscheidung (F04), Passwort- und Aktivierungs-Wiederherstellung (F11).
|
||||||
|
4. **Alltagskorrekturen direkt ermöglichen:** Einheitliche Kalenderdetails und editierbare Mitbring-Einträge (F06), bearbeitbare Mitteilungen mit eigenständigem Versandstatus (F07).
|
||||||
|
5. **Weniger wiederholte Eingaben:** Tagesbezogene Arbeitszeitformulare und Ein-Tages-Anträge (F09), geführte Ersteinrichtung (F08), eindeutig beschriftete Ämter (F10) und echte Menü-Zahlen (F12).
|
||||||
|
|
||||||
|
Vor der Umsetzung der jeweiligen Zusatzregel noch zu entscheiden: Umgang mit mehrtägigen Abwesenheiten über die Grenze „heute“ hinweg; Teilnahme von Admins/Koordination ohne eigene Kinder; automatische Verfügbarkeits-Erinnerungen samt Termin; E-Mail zur ersten Planveröffentlichung. Diese Punkte verhindern nicht die bereits eindeutig beschriebenen Korrekturen.
|
||||||
|
|
||||||
|
## Verifikation und nächster Arbeitsstand
|
||||||
|
|
||||||
|
Die Quellcodebefunde wurden gegen den genannten Commit geprüft. Im lokalen Browser bestätigt wurden insbesondere der nicht nutzbare Eltern-Abwesenheitslink, die fehlenden Einladungsaktionen in Familien-Details, gleich benannte Ämter-Schaltflächen sowie unterschiedliche Detailzugänge im Kalender.
|
||||||
|
|
||||||
|
Dieses Dokument liegt auf der eigenen Arbeitsbranch `codex/flow-audit`. Für die Dokumentation wurden keine Anwendungstests oder ein neuer Build benötigt. Bei der Umsetzung gehören gezielte Tests der Datums-/Rollenregeln und der Mailfehlerfälle zu den jeweiligen Paketen.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Familieneinladungen und Abwesenheitsverwaltung
|
||||||
|
|
||||||
|
Stand: 9. Oktober 2026. Umsetzung der Befunde F01 und F02 aus der [Ablaufprüfung](ABLAUFPRUEFUNG.md), einschließlich Entfernung des künstlichen Gruppenfilters aus F12.
|
||||||
|
|
||||||
|
## Familieneinladungen
|
||||||
|
|
||||||
|
- Die Familienanlage erfasst weiterhin Eltern und Kinder gemeinsam. Die Familie, Konten, Aktivierungslinks und Versandaufträge werden gemeinsam gespeichert.
|
||||||
|
- Scheitert danach der Mailversand, bleibt die Familienanlage erfolgreich. Das Formular schließt, die Liste aktualisiert sich und eine Warnung weist auf den erneuten Versand hin. Auch die Ergänzung eines Elternteils in einer bestehenden Familie folgt diesem Ablauf.
|
||||||
|
- Jeder Empfänger wird bearbeitet, auch wenn eine andere Einladung fehlschlägt.
|
||||||
|
- Tabelle, Kartenansicht und Familien-Details zeigen den Status je Elternkonto: aktiviert, Aktivierung offen, Versand läuft, versendet, fehlgeschlagen oder abgelaufen. Soweit vorhanden, wird das Ablaufdatum angezeigt.
|
||||||
|
- Kita-Admins können die Einladung innerhalb ihrer Kita beim jeweiligen Elternteil erneut senden. Aktivierte Konten werden nicht erneut eingeladen. Parallel ausgelöste Versandversuche werden gegen doppelte Bearbeitung geschützt.
|
||||||
|
- Ein noch gültiger, fehlgeschlagener Versand wird mit demselben Link wiederholt. Bei abgelaufenem oder zuvor erfolgreich versendetem Link wird ein neuer Link für sieben Tage erstellt; ältere Aktivierungslinks des Kontos werden ersetzt. Direkt nach einem erfolgreichen Versand gilt eine Minute Abstand bis zum erneuten Versand.
|
||||||
|
- Wird die Adresse eines noch nicht aktivierten Kontos korrigiert, versendet das Speichern automatisch eine neue Einladung an diese Adresse. Solange die Adressänderung ungespeichert ist, ist der separate erneute Versand gesperrt.
|
||||||
|
- Bereits bestehende Elternkonten und Aktivierungslinks bleiben gültig. Alte Konten ohne gespeicherten Versandstatus erscheinen als „Aktivierung offen“; beim erneuten Senden wird der Status ergänzt. Das Löschen und Neuanlegen einer Familie ist dafür nicht nötig.
|
||||||
|
- Nach der Aktivierung wird der Einladungsstatus abgeschlossen. „Versendet“ bedeutet, dass der Mailtransport die Nachricht angenommen hat; eine tatsächliche Zustellung oder ein späterer Bounce wird hier nicht überwacht.
|
||||||
|
|
||||||
|
Bei Eingabefehlern bleiben die Formulardaten erhalten. Nach einem erfolgreichen Vorgang öffnet sich das Formular wieder mit einem frischen Zustand. Die Kinder-Schaltfläche entfernt das tatsächlich gewählte Kind aus dem Entwurf.
|
||||||
|
|
||||||
|
## Kinder-Abwesenheiten
|
||||||
|
|
||||||
|
„Meine Abwesenheiten“ ist über das Menü und die Dashboard-Karte erreichbar. Die Seite zeigt ausschließlich Meldungen des eigenen Haushalts, getrennt nach aktuellen/zukünftigen und vergangenen Meldungen, mit jeweils bis zu 20 Meldungen pro Seite.
|
||||||
|
|
||||||
|
| Rolle | Neue Meldung | Ändern oder zurücknehmen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Eltern | Eigene aktive Kinder, ab heute | Eigene Meldungen, deren Beginn nach heute liegt; auch der neue Beginn muss zukünftig bleiben. |
|
||||||
|
| Kita-Admin, Notdienst-Koordination, Fachkraft | Der bestehende Dashboard-Meldeweg betrifft den eigenen Haushalt. | Vorhandene Meldungen innerhalb der eigenen Kita, einschließlich laufender Meldungen, in der Kita-Tagesübersicht korrigieren oder zurücknehmen. |
|
||||||
|
|
||||||
|
Die vom Nutzer bestätigte Mehrtagesregel gilt für die gesamte Meldung: **Sobald sie begonnen hat, übernimmt die Kita auch die noch zukünftigen Tage.** Eltern können die zukünftige Hälfte einer laufenden Meldung nicht eigenständig abtrennen.
|
||||||
|
|
||||||
|
Die Bearbeitung zeigt Zeitraum, Grund und Bemerkung in einem Dialog. Bei Verschieben des Beginns hinter das bisherige Ende wird das Ende angepasst. Eine Rücknahme erhält einen eigenen Bestätigungsdialog. Laufende Meldungen zeigen Eltern einen verständlichen Hinweis auf die Kita.
|
||||||
|
|
||||||
|
Die Regeln gelten auch serverseitig:
|
||||||
|
|
||||||
|
- Aktuelle Rollen und Haushaltszugehörigkeit werden vor Schreibzugriffen aus der Datenbank geprüft.
|
||||||
|
- Kalendertage werden unabhängig von der Server-Zeitzone als UTC-Datum gespeichert; die Grenze „heute“ folgt `Europe/Berlin`.
|
||||||
|
- Ungültige Tage, umgekehrte Zeiträume und Überschneidungen werden abgewiesen. Gleichzeitige Meldungen für dasselbe Kind werden serialisiert.
|
||||||
|
- Ein veralteter Bearbeitungsstand überschreibt keine zwischenzeitliche Änderung. Die Oberfläche bietet bei diesem Konflikt das Aktualisieren der Ansicht an.
|
||||||
|
- Erfolgreiche Änderungen aktualisieren Dashboard, eigene Verwaltung und Kita-Tagesübersicht.
|
||||||
|
|
||||||
|
Der bisherige Gruppenfilter „Krippe/Elementar“ wurde entfernt, weil keine tatsächliche Gruppenzuordnung hinterlegt ist. Ein fachlicher Gruppenfilter bleibt ein eigener späterer Ausbau.
|
||||||
|
|
||||||
|
## Deployment und Betrieb
|
||||||
|
|
||||||
|
Das Prisma-Modell `Invitation` erhält einen optionalen Bezug zum Elternkonto sowie Versandzeit, Versuchszähler, Fehlerstatus und eine zeitlich begrenzte Versandsperre. Vorhandene Datensätze müssen nicht neu angelegt werden.
|
||||||
|
|
||||||
|
Der vorgeschaltete Compose-Dienst `migrate` führt `prisma migrate deploy` und die eingeschränkten Runtime-Grants aus. Die versionierte Migration `20261010000000_family_invitation_flows` ergänzt die Felder und prüft die Mandantenzuordnung des Empfängers. Der App-Start benötigt keine Schema- oder Administrationsrechte. **Es sind keine neuen Umgebungsvariablen nötig.** Resend-Absender, API-Schlüssel und App-Adresse werden wie bisher verwendet.
|
||||||
|
|
||||||
|
Bei einem eigenen Startablauf die versionierten Migrationen und Runtime-Grants vor dem neuen App-Code ausführen; siehe die zentrale Deployment-Anleitung. Kein `prisma db push` und kein historisches SQL-Upgrade zusätzlich zu den Migrationen verwenden.
|
||||||
|
|
||||||
|
Die normalen Sicherungs- und Schema-Prüfschritte gelten weiterhin; siehe [Deployment und Mailversand](../deploy/DEPLOYMENT_UND_MAILVERSAND.md). Für Familieneinladungen ist kein zusätzlicher Cronjob erforderlich. Fehlgeschlagene oder unterbrochene Versandversuche werden gezielt durch Kita-Admins wiederholt.
|
||||||
|
|
||||||
|
## Prüfung
|
||||||
|
|
||||||
|
- Unit-Tests für echte Kalendertage, Berliner Mitternachts-/Zeitumstellungsgrenzen und Einladungszustände.
|
||||||
|
- PostgreSQL-Integrationstests mit simuliertem Mailtransport: Speicherung trotz Mailfehler, zweiter Empfänger, gezieltes erneutes Senden, Ablauf/Erneuerung, Alt-Konten, paralleler Versand, Aktivierungsabschluss, Adresskorrektur und Berechtigungen.
|
||||||
|
- PostgreSQL-Integrationstests für Eltern- und Kita-Korrekturen, begonnene Mehrtagesmeldungen, fremde Haushalte/Kitas, Überschneidungen, konkurrierende Erstellung und veraltete Bearbeitungsstände.
|
||||||
|
- Wiederholbarkeit des SQL-Upgrades auf leerem und bestehendem Testschema mit Erhalt vorhandener Tokens.
|
||||||
|
- Lokale Browserprüfung mit deaktiviertem echtem Mailversand: Anlage und Bearbeitung schließen trotz Versandwarnung; Formulare behalten Fehler-Eingaben und lassen sich erneut öffnen; zukünftige Elternmeldung und laufende Kita-Meldung werden erfolgreich korrigiert.
|
||||||
|
- TypeScript, ESLint und Produktionsbuild.
|
||||||
|
|
||||||
|
Testbefehle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:family-absence
|
||||||
|
TEST_DATABASE_URL='postgresql://USER@127.0.0.1:PORT/TEST_DATABASE' npm run test:family-absence:integration
|
||||||
|
```
|
||||||
|
|
||||||
|
Integrationstests akzeptieren nur eine ausdrücklich benannte lokale Testdatenbank. Die Browserprüfung nutzt ausschließlich synthetische Daten und verschickt keine echten E-Mails.
|
||||||
@@ -1,5 +1,111 @@
|
|||||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
||||||
|
|
||||||
|
## Ergänzung vom 13.10.2026: Landingpage und SEO
|
||||||
|
|
||||||
|
- Die öffentliche Landingpage beschreibt jetzt sechs Bereiche, einschließlich Personal/Arbeitszeit mit eigener Detailseite `/features/personal-arbeitszeit`. Rollenvergabe, Wiederherstellung des Kontozugangs, Kalender-Rückmeldungen, Mitbringkorrekturen, zukünftige Abwesenheitsänderungen, Notdienst-Erinnerungen/Veröffentlichung und Mitteilungskorrekturen samt Versandübersicht sind in den passenden Texten ergänzt. Die Darstellung behält das vorhandene Design bei und ist auf Desktop sowie bei 390 und 360 Pixeln geprüft; der mobile Logo-Umbruch wurde behoben.
|
||||||
|
- Veraltete Aussagen zu „1-Klick-Alarmierung“, Raumbuchungen und Lesebestätigungen wurden durch die tatsächlich vorhandenen Abläufe ersetzt. Der persönliche Ungelesen-Status wird nicht als Nachweis einer tatsächlichen Kenntnisnahme oder als Admin-Auswertung beworben.
|
||||||
|
- `src/lib/marketing/seo.ts` bündelt öffentliche Domain, Titel, Beschreibungen, Canonicals und Vorschaudaten. Öffentliche Seiten besitzen eigene absolute Canonicals und `og:url`; Titel wiederholen den Markennamen nicht. Open Graph und Twitter verwenden passende Beschreibungen und das vorhandene, tatsächlich 1200 × 630 Pixel große Bild. Sein Alternativtext beschreibt die sichtbaren Spielklötze.
|
||||||
|
- Die Startseite liefert JSON-LD für WebSite, WebPage und SoftwareApplication; Feature-Seiten ergänzen BreadcrumbList und ihre eigene WebPage. Die Daten stammen aus den vorhandenen Produktbeschreibungen, enthalten keine erfundenen Bewertungen, Preise oder Anbieterangaben und werden gegen eingeschleuste HTML-Script-Enden abgesichert. Die Scripts verwenden den vorhandenen CSP-Nonce. Dies ist keine Zusage besonderer Google-Suchergebnisdarstellungen.
|
||||||
|
- Die Sitemap enthält ausschließlich neun öffentliche Canonical-Seiten: Startseite, sechs Feature-Seiten, Impressum und Datenschutz. Das Änderungsdatum folgt der tatsächlichen Änderung der Marketinginhalte (`marketingLastModified`) und wechselt nicht mehr bei jedem Build. Für rechtliche Seiten wird kein unbekanntes Änderungsdatum erfunden. Bei weiteren Änderungen der Produkttexte ist dieses Datum gezielt fortzuschreiben.
|
||||||
|
- Indexierung ist im Root-Layout standardmäßig ausgeschaltet; nur die öffentlichen Seiten schalten sie ausdrücklich ein. Private Seiten behalten `noindex, nofollow` und `private, no-store` auch als HTTP-Header. Login, Registrierung und Wiederherstellungs-Anforderungsformular bleiben für Crawler erreichbar, damit sie die `noindex`-Markierung lesen können. Interne und Tokenrouten bleiben zusätzlich in robots.txt ausgeschlossen. robots.txt ist keine Zugriffskontrolle und eine blockierte URL allein garantiert keine Entfernung aus einem Suchindex.
|
||||||
|
|
||||||
|
Prüfung: `npm run test:seo` (11 Tests gegen tatsächliche Metadaten-/Sitemap-/JSON-LD-Funktionen) sowie `TEST_SEO_ORIGIN=http://127.0.0.1:PORT npm run test:seo:http` (24 Tests gegen den lokal gestarteten Standalone-Produktionsbuild). Die HTTP-Prüfung umfasst alle neun öffentlichen HTML-Seiten, Social-Crawler-Metadaten direkt im Head, strukturierte Daten und CSP-Nonce, Sitemap, robots.txt, tatsächliche PNG-Bildmaße, private Seiten/Weiterleitungen und unbekannte Feature-URLs mit 404/noindex. Insgesamt 35 Tests erfolgreich, keine übersprungenen Tests. Build und TypeScript erfolgreich; ESLint ohne Fehler, mit fünf bereits bestehenden Warnungen.
|
||||||
|
|
||||||
|
Die bestehende Produktions-Landingpage, robots.txt und Sitemap wurden zusätzlich lesend abgeglichen. Die neuen Änderungen wurden lokal geprüft und sind noch nicht veröffentlicht. Für diese SEO-Ergänzung gibt es keine weitere Datenbankmigration; der Branch baut auf der vorherigen Mitteilungs-Etappe mit deren eigener Migration auf. Es wurden keine produktiven Daten verändert, Anfragen versendet oder Analytics-Ereignisse ausgelöst.
|
||||||
|
|
||||||
|
Offen: Impressum und Datenschutzhinweise enthalten im bestehenden Seiteninhalt weiterhin Platzhalter für Anbieter, Anschrift, Kontakt, verantwortliche Stelle und Dienstleister. Diese Angaben müssen von der verantwortlichen Person geliefert werden; die Metadaten-Ergänzung ersetzt sie nicht. Search-Console-Zugriff, tatsächlicher Google-Indexierungsstand und reale Nutzungs-/Performance-Messwerte waren nicht Teil der verfügbaren Prüfung.
|
||||||
|
|
||||||
|
Grundlagen: [Google zu Canonical-URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls), [Sitemap-Änderungsdatum](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) und [noindex](https://developers.google.com/search/docs/crawling-indexing/block-indexing); außerdem die mit Next.js 16.4 gelieferten Metadaten- und JSON-LD-Guides.
|
||||||
|
|
||||||
|
|
||||||
|
## Ergänzung vom 13.10.2026: Mitteilungen korrigieren und Versand verwalten
|
||||||
|
|
||||||
|
- Unter `/dashboard/admin/news` können Kita-Admins alle eigenen Beiträge nach Titel/Text durchsuchen und in Seiten mit zwölf Meldungen durchblättern. „Bearbeiten & Versand“ öffnet die Korrektur und den Versandstatus. Eltern- und Koordinationskonten erhalten keinen Verwaltungszugriff.
|
||||||
|
- Titel und Markdown-Text sind bearbeitbar. Empfängergruppe, ursprünglicher Autor und geschützte Anhänge bleiben erhalten. Gleichzeitige bzw. veraltete Korrekturen überschreiben keinen neueren Stand; Akteur, Kita, aktive Sitzung und Adminrolle werden beim Speichern erneut geprüft.
|
||||||
|
- Eine tatsächliche Textänderung erhält einen sichtbaren Aktualisierungszeitpunkt, setzt bisherige Lesemarkierungen zurück und rückt den Beitrag im Elternfeed nach vorne. Unverändertes Speichern erhält Lesestatus und Beitragsfassung. Ein noch geöffnetes altes Dialogfenster kann eine neuere Fassung nicht als gelesen markieren.
|
||||||
|
- Die aktuelle Fassung kann beim Speichern ausdrücklich auch per E-Mail vorgemerkt werden. Einmalige Aufträge je Beitrag/Fassung/Konto entstehen innerhalb derselben Datenbanktransaktion. Ohne ausdrückliche Auswahl erzeugt eine Korrektur keine neue Mailrunde. Ungesendete Aufträge älterer Fassungen werden übersprungen; historische erfolgreiche Sendungen bleiben in der Versandübersicht nachvollziehbar.
|
||||||
|
- Jede Person hat einen eigenen Status: ausstehend, gesendet, fehlgeschlagen oder übersprungen. „Gesendet“ bedeutet die Annahme durch den Maildienst, keine Lesebestätigung oder garantierte Postfachzustellung. Bestehende Beiträge erhalten keinen erfundenen historischen Versandstatus. Aktive, verifizierte Konten mit Zustimmung und passender Empfängergruppe werden vorbereitet; vor jedem Versand werden diese Voraussetzungen erneut geprüft. Haushaltskonten behalten ihre Eltern-Mailzugehörigkeit bei Admin-/Koordinationsrechten; Fachkräfte mit Adminrechten erhalten über ihr verknüpftes Personalprofil weiterhin Teammeldungen. Systemadmins gehören nicht zu diesen Empfängergruppen.
|
||||||
|
- Erste Versuche laufen nach der Antwort über Next.js `after`. Die gespeicherten Aufträge überleben Neustarts und werden zusätzlich vom bestehenden geschützten Hintergrundjob `/api/jobs/notdienstalarme` verarbeitet: zwölf fällige Aufträge je Lauf, bis zu vier parallel, wiederholte Fehler mit wachsendem Abstand bis sechs Stunden. Reservierungen laufen nach fünf Minuten ab, Wiederholungen behalten denselben Idempotenzschlüssel. Erfolgreiche Sendungen werden nicht erneut vorgemerkt. Der Maildienst begrenzt die zeitliche Gültigkeit seiner Idempotenzschlüssel; eine absolute Zustellgarantie über beliebig lange Abbrüche gibt es nicht.
|
||||||
|
- „Fehlgeschlagene erneut versuchen“ zieht ausschließlich fehlgeschlagene Aufträge der eigenen Kita und aktuellen Beitragsfassung vor. Während eines laufenden Versands wird eine Korrektur kurzzeitig zurückgewiesen, damit unterschiedliche Texte nicht unter derselben Fassung versendet werden. Verwaltung und Versandversuche haben eigene Anfragelimits.
|
||||||
|
- Aufträge mit Anhängen werden erst nach vollständiger Speicherung freigegeben. Dateischreibfehler nehmen Beitrag und Aufträge gemeinsam zurück. Nach einem Abbruch zwischen fertig geschriebenen Dateien und Freigabe prüft der Hintergrundlauf nach fünf Minuten die gespeicherten Dateigrößen und gibt nur vollständige Anhänge frei. Fehlende oder teilweise geschriebene Dateien bleiben gesperrt und benötigen eine Prüfung durch die Hosting-Administration.
|
||||||
|
|
||||||
|
Vor dem produktiven Start die Migration `20261012010000_announcement_delivery` über den bestehenden Maintenance-/Deployment-Ablauf anwenden (`npm run db:migrate:deploy`) und die Runtime-Berechtigungen wie gewohnt erneuern (`npm run db:provision-runtime`). Sie ergänzt Beitragsfassung, Änderungszeitpunkt, Freigabe und Versandaufträge; bestehende Beiträge behalten ihre bisherige Reihenfolge. Zwei zusammengesetzte Fremdschlüssel sichern die Beziehungen zur selben Kita zusätzlich in PostgreSQL. Keine neue Umgebungsvariable: bestehende Mailkonfiguration und App-Adresse werden verwendet. Automatische Wiederholungen benötigen den bereits vorhandenen Worker mit `NOTDIENST_JOB_SECRET` (mindestens 32 Zeichen); der gewöhnliche `npm run start` startet diesen Worker nicht. Fehlende Jobkonfiguration wird in der Versandansicht angezeigt.
|
||||||
|
|
||||||
|
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_announcements_test npm run test:announcements` nach Anwendung der Migrationen auf dieser separaten lokalen Datenbank. 31 neue PostgreSQL-Integrationstests erfolgreich, darunter drei konkurrierende Worker mit 24 Empfängern. Dazu 84 bestehende Sicherheits-, Notdienst-, Mailtransport- und Elternablaufprüfungen (115 insgesamt, keine übersprungen). Die Tests ersetzen Request-/UI-Grenzen, Mailtransport und bei Dateiabbruchtests die Dateisystemgrenze; Anwendungscode und PostgreSQL-Transaktionen werden tatsächlich ausgeführt. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen.
|
||||||
|
|
||||||
|
Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: Suche und zweite Verwaltungsseite, ältere Meldung direkt korrigieren, isolierte Wiederholung einer fehlgeschlagenen Sendung, Korrektur ohne neue Mail, ausdrücklicher Versand der aktuellen Fassung sowie frühere Versandstände. Eltern sehen ältere Korrekturen wieder vorne und ungelesen; Öffnen speichert die aktuelle Lesemarkierung. Keine horizontale Überbreite. Der echte geschützte Hintergrundjob hat eine fällige synthetische Sendung verarbeitet. Es wurden keine produktiven Daten verändert und keine echten E-Mails verschickt.
|
||||||
|
|
||||||
|
|
||||||
|
## Ergänzung vom 12.10.2026: Kontozugang und weniger wiederholte Eingaben
|
||||||
|
|
||||||
|
- Die Ämter-Schaltflächen nennen die betroffene Person („Ämter: Vorname“) und tragen einen vollständigen zugänglichen Namen. Der Einrichtungshinweis wird je Konto in diesem Browser ausgeblendet. Das alte gemeinsame Ausblenden gilt nicht mehr für alle Konten; fehlender Browser-Speicher verhindert die Nutzung nicht.
|
||||||
|
- Unter `/passwort-vergessen` können aktivierte Konten einen Wiederherstellungslink anfordern. Die öffentliche Antwort bleibt für bestehende, unbekannte, deaktivierte und begrenzte Anfragen gleich. Kontosuche und Mailversand laufen nach der Antwort über Next.js `after`; ein Fehler verrät öffentlich keine Kontodaten.
|
||||||
|
- Reset-Links sind 30 Minuten gültig, einmal nutzbar und an die aktuelle Sitzungsfassung gebunden. Die vorhandene Verifikationstabelle speichert ausschließlich den Tokenhash und einen eigenen Reset-Zweck. Einladungslinks können kein Passwort zurücksetzen. Neue Reset-Anfragen ersetzen ältere Links; zwischenzeitliche E-Mail-/Passwortänderungen, administrative Kontosperren oder Löschungen verhindern die Verwendung alter Links. Fehlgeschlagener Versand entfernt ausschließlich den eigenen neu erzeugten Link.
|
||||||
|
- Im Profil lässt sich das Passwort mit dem aktuellen Passwort und übereinstimmender Bestätigung ändern. Sowohl Wiederherstellung als auch eigene Änderung erhöhen die Sitzungsfassung und beenden alte Anmeldungen; eine neue Anmeldung ist erforderlich. Persönliche Alarmlinks, die an diese frühere Sitzungsfassung gebunden sind, werden ebenfalls ungültig. Kontoaktivierung, Kita, Haushalt, Rollen und Einwilligungen bleiben erhalten. Es gelten weiterhin mindestens acht Zeichen und die bcrypt-Grenze von 72 UTF-8-Bytes.
|
||||||
|
- Anfragebegrenzungen: drei Reset-Anfragen je E-Mail/Stunde, fünf Reset-Abschlussversuche je Link/15 Minuten und fünf eigene Passwortänderungsversuche je Konto/15 Minuten; zusätzlich die bestehenden gemeinsamen und ggf. Proxy-IP-Limits. Bestehende Mailkonfiguration und App-Adresse werden verwendet. Keine neue Umgebungsvariable oder Datenbankmigration erforderlich.
|
||||||
|
- Die Kalender-Agenda öffnet den bereits vorhandenen Tagesdetaildialog mit Beschreibung, Rückmeldung, Mitbringliste und berechtigten Verwaltungsaktionen. Ansicht und Suche bleiben beim Schließen erhalten. Die Agenda passt sich auf schmalen Bildschirmen an.
|
||||||
|
- Neue Arbeitszeitformulare übernehmen die betrachtete Woche bzw. den ausgewählten Tag. Tage in der Zukunft sind nicht erfassbar. „Speichern und weiteren Block erfassen“ hält den tatsächlich eingegebenen Tag fest, leert die Uhrzeiten, setzt die Pause auf null und fokussiert den Beginn. Bestehende Zeiteinträge werden weiterhin einzeln bearbeitet; serverseitige Zeit-, Überschneidungs- und Freigaberegeln bleiben maßgeblich.
|
||||||
|
- Freizeitausgleich und halber Urlaubstag benötigen ein Datum. Das Ende wird aus diesem Datum übernommen; mehrtägiger Urlaub und Krankheit behalten Von/Bis. Die Berechnungs- und Genehmigungsregeln ändern sich nicht.
|
||||||
|
|
||||||
|
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_account_test npm run test:account` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 20 neuen PostgreSQL-Tests prüfen den tatsächlichen Passwortdienst, Server Actions, bcrypt und JWT-Entwertung mit ersetztem Mailtransport. Dazu 139 bestehende Sicherheits-, Rollen-, Kalender-, Familien- und Personaltests erfolgreich (159 insgesamt, keine übersprungenen Tests).
|
||||||
|
|
||||||
|
Browser-Abnahme mit synthetischen Konten auf Desktop und bei 390 Pixeln: neutrale Reset-Bestätigung und tatsächliche nachgelagerte Vorbereitung, gültige/ungültige Linkseiten, Passwortformular im Profil, getrennte Einrichtungshinweise bei Konto-Wechsel, eindeutige Ämter, Agenda-Details samt gespeichertem Mitbringbeitrag und erhaltenem Suchfilter. Arbeitszeit in vergangener Woche und am ausgewählten Tag geprüft; zwei Blöcke mit 60 und 45 Minuten am selben eingegebenen Tag gespeichert. Halber Urlaubstag mit identischem Start/Ende gespeichert; Freizeitausgleich zeigt ebenfalls nur ein Datum. Passwortänderungen selbst wurden in den Integrationstests ausgeführt. Es wurden keine echten Mails verschickt oder produktiven Daten verändert. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit fünf bestehenden Warnungen.
|
||||||
|
|
||||||
|
|
||||||
|
## Ergänzung vom 12.10.2026: Manuelle Planung und Zugriffsrechte
|
||||||
|
|
||||||
|
- Die manuelle Notdienst-Zuweisung gruppiert verfügbare Haushalte zuerst. Familien ohne Verfügbarkeit für den gewählten Tag bleiben als abgestimmter Sonderfall auswählbar und werden nach einer solchen Einteilung ausdrücklich gekennzeichnet. Geschwister zählen als ein Haushalt; Einteilungszahlen gelten ebenfalls je Haushalt. Die Anzeige verwendet dieselben gültigen Monatstage wie die Planung. Bestehende Alarm- und Schließtagssperren bleiben bestehen.
|
||||||
|
- Kita-Admins verwalten Zugriffsrollen unter `/dashboard/zugriffsrechte` sowie direkt bei Personen in Familien- und Teamverwaltung. Die Übersicht enthält auch Konten ohne eigenen Haushalt, eine Suche und die letzten 15 protokollierten Rollenänderungen der eigenen Kita.
|
||||||
|
- Haushaltskonten können Eltern, Notdienst-Koordination oder Kita-Admin sein. Ohne Haushalt stehen Koordination und Kita-Admin zur Verfügung. Konten mit Personalprofil können Fachkraft oder Kita-Admin sein; Arbeitszeiten, Anträge und Haushalt bleiben erhalten. Ein Personalprofil wird durch eine Rollenänderung nicht neu angelegt. Systemadmins werden hier nicht verwaltet. Ämter wie „Vorstand“ verleihen keine Zugriffsrechte.
|
||||||
|
- Rollenänderungen prüfen Akteur und Zielkonto erneut innerhalb einer serialisierbaren Transaktion. Eine zwischenzeitlich geänderte Rolle wird nicht überschrieben. Das bestehende Login liest die Rolle bei jeder Anfrage neu; eigene Herabstufung führt zurück zur Übersicht. Offene Aktivierungslinks benötigen weiterhin die Aktivierung, bevor Zugang entsteht.
|
||||||
|
- Mindestens ein aktiver Kita-Admin mit verifiziertem Konto, Passwort und Datenschutzzustimmung bleibt erhalten. Herabstufung, eigene Kontolöschung, Support-Löschung/-Sperre und eine E-Mail-Änderung mit erneuter Aktivierung berücksichtigen denselben Schutz. Offene Einladungen, deaktivierte/gesperrte Konten oder fehlende Zustimmung zählen nicht als Ersatz. Gleichzeitige Änderungen werden abgefangen.
|
||||||
|
- Eltern mit Admin-/Koordinationsrolle erhalten weiterhin Haushaltsalarme, können über ihre persönlichen Links antworten und erhalten Mails nach manueller Umteilung. Fachkräfte mit Admin-Rolle können ihr verknüpftes Personalprofil weiter nutzen; persönliche Benachrichtigungseinstellungen und offene Fachkraftaktivierungen bleiben funktionsfähig.
|
||||||
|
- Kein Schema-Upgrade erforderlich.
|
||||||
|
|
||||||
|
Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_roles_test npm run test:roles` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 27 Rollen-/PostgreSQL-Tests und sechs tatsächlichen Komponenten-Rendertests verwenden synthetische Konten und ersetzte Mailtransporte.
|
||||||
|
|
||||||
|
Abnahme: 33 neue Tests und 193 bestehende Sicherheits-, Planungs-, Kalender-, Versand-, Familien-, Personal- und Alarmtests erfolgreich (226 insgesamt, keine übersprungenen Tests). Browserprüfung auf Desktop und bei 390 Pixeln: verfügbare und bewusst abweichende Einteilung, Rollenänderung aus Familien-/Teamverwaltung, Suche, Änderungshistorie, Schutz für einzigen Admin und eigene Herabstufung mit unmittelbar verweigertem Verwaltungszugriff. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit sieben bestehenden Warnungen.
|
||||||
|
|
||||||
|
|
||||||
|
## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen
|
||||||
|
|
||||||
|
- Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe.
|
||||||
|
- Die Erinnerungsfrist bezieht sich jetzt ausdrücklich auf den 26. als Ende der Eintragsfrist. Standard: sieben Tage vorher ab 9 Uhr Berliner Zeit; 0 deaktiviert die automatische Runde. Verpasste Läufe werden innerhalb der noch offenen Frist nachgeholt. Jede Kita/Folgemonat erhält eine automatische Runde; manuelle Erinnerungen sind je Konto/Tag dedupliziert.
|
||||||
|
- Beim ersten Veröffentlichen werden Mailaufträge in derselben Transaktion gespeichert. Pro erreichbarem Konto wird eine gemeinsame Nachricht mit den aktuellen eigenen Diensten erzeugt. Mehrfaches Veröffentlichen erzeugt keine zweite Runde. Mailfehler ändern den veröffentlichten Status nicht.
|
||||||
|
- Die Monatsplanung zeigt Versandzahlen und Einzelstatus sowie eine manuelle Wiederholung offener/fehlgeschlagener Nachrichten. Bereits versendete oder inzwischen überholte Aufträge werden dabei nicht neu verschickt.
|
||||||
|
- Der bestehende geschützte Notdienstjob verarbeitet die Nachrichten automatisch. Gespeicherte Reservierungen, Idempotenzschlüssel, zunehmende Wiederholungsabstände und erneute Berechtigungs-/Einteilungsprüfung sichern die Verarbeitung. Der Produktionsstart startet einen abgebrochenen Worker erneut.
|
||||||
|
- Additive Migration: `20261012000000_notdienst_notifications`. Sie erhält die bisherigen Constraints und ergänzt drei weitere Tenant-Fremdschlüssel. Einrichtung und Grenzen stehen in [deploy/README.md](../deploy/README.md).
|
||||||
|
|
||||||
|
Prüfbefehle: `npm run test:mail`, `npm run test:runtime`, `npm run test:notdienst` und `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_notifications_test npm run test:notdienst:notifications` nach Anwendung der Migrationen auf dieser separaten Testdatenbank. Die Versandtests verwenden ersetzte Transporte; reale Eltern werden nicht angeschrieben.
|
||||||
|
|
||||||
|
Abnahme: 33 neue Versand-Integrationstests, vier Tests des tatsächlichen SDK-Transports, zwei Supervisor-Tests, sechs Datums-/Fristtests sowie 106 bestehende Sicherheits-, Planungs-, Kalender- und Elternablauftests erfolgreich (151 insgesamt, keine übersprungenen Tests). Browserprüfung mit lokalem Mailserver: automatische Erinnerung einmalig, Veröffentlichung trotz simuliertem Versandfehler, sichtbare Einzelstatus, erfolgreiche Wiederholung und mobile Anzeige. TypeScript und Produktionsbuild erfolgreich; ESLint ohne Fehler mit acht bestehenden Warnungen.
|
||||||
|
Zusätzliche Upgrade-Prüfung gegen das vorige Schema: vorhandene Familien, Konten, Kinder, veröffentlichter Plan und Einteilung unverändert erhalten; keine nachträglichen Veröffentlichungsmails angelegt. Die Zahl der ergänzenden Tenant-Fremdschlüssel steigt von 41 auf 44.
|
||||||
|
|
||||||
|
## Ergänzung vom 12.10.2026: Kalender und kleine Bedienungsverbesserungen
|
||||||
|
|
||||||
|
- Eigene Mitbringbeiträge lassen sich direkt mit Speichern/Abbrechen bearbeiten. Enter speichert; Escape schließt den Termindialog und verwirft den Entwurf. Eingaben werden auf 1 bis 500 Zeichen begrenzt; fremde Beiträge können auch von Admin/Koordination nicht umgeschrieben werden. Ein bestätigter Termin mit aktiver Mitbringliste und die Kita-/Autorzuordnung werden serverseitig geprüft.
|
||||||
|
- Eltern mit Admin- oder Koordinationsrolle können für den eigenen Haushalt Dienste tauschen. Kalenderknöpfe und offene eingehende/ausgehende Anfragen stehen ihnen ebenfalls zur Verfügung. Tauschmails berücksichtigen verifizierte, aktive Elternkonten aller drei Rollen im jeweils beteiligten Haushalt. Konten ohne eigenen Haushalt erhalten dadurch kein Tauschrecht; fremde Angebote und aktive Alarme bleiben gesperrt.
|
||||||
|
- Die fest eingetragene „4“ am Menüpunkt Notdienst wurde entfernt.
|
||||||
|
- Kein Schema-Upgrade erforderlich. Automatische Erinnerungen und Veröffentlichungsmails bleiben die nächste Etappe.
|
||||||
|
|
||||||
|
Prüfbefehl für die neuen Abläufe: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_quickwins_test npm run test:calendar:integration`. Er verlangt eine ausdrücklich benannte lokale Testdatenbank mit den vorhandenen Migrationen. Die 26 Fälle prüfen echte Server Actions und PostgreSQL; Mailtransport und Anmeldesession werden isoliert ersetzt. Die bestehenden Elternabläufe wurden ebenfalls mit 9 Integrationstests und 9 Berechnungstests geprüft.
|
||||||
|
|
||||||
|
Im Browser geprüft: eigener Beitrag speichern und abbrechen, Anzeige bei 390 Pixeln, fehlender Bearbeiten-Knopf bei fremden Beiträgen, Navigation ohne festen Zähler, Admin-Tauschanfrage mit eigenem Angebot sowie Anzeige und Annahme durch Koordination. E-Mails wurden dabei ausschließlich im Entwicklungsmodus simuliert.
|
||||||
|
|
||||||
|
Abnahme dieser Etappe: 26 neue Integrationstests, 18 bestehende Elternablauftests und 28 Sicherheitstests erfolgreich (72 insgesamt, keine übersprungenen Tests). TypeScript und Produktionsbuild erfolgreich; projektweites ESLint ohne Fehler mit acht bestehenden Warnungen.
|
||||||
|
|
||||||
|
## Ergänzung vom 12.10.2026: Verlässliche Notdienst-Planungsregeln
|
||||||
|
|
||||||
|
- Das Verfügbarkeitssoll wird aus allen aktiven Kindern des angemeldeten Haushalts berechnet. Ausgelassene, doppelte, fremde oder inaktive Kinder werden zurückgewiesen; doppelte Datumsangaben zählen nur einmal.
|
||||||
|
- Oberfläche, Speicherung, Generierung und manuelle Zuweisung verwenden gemeinsame Regeln: nur Werktage des Planungsmonats, ohne bestätigte Schließtage und Teamtage. Mehrtägige Termine schließen beide Randtage ein; Kalenderzeiten werden in `Europe/Berlin` ausgewertet. Ausstehende/abgelehnte Termine und Termine anderer Kitas blockieren nichts.
|
||||||
|
- Alte ungültige Verfügbarkeiten werden nicht für neue Pläne verwendet. Vor der Veröffentlichung werden Entwürfe auf zulässige Tage und aktive Kinder geprüft. Ungültige vorhandene Einteilungen bleiben sichtbar und können über „Manuell zuweisen“ entfernt werden; aktive Alarme und vergangene veröffentlichte Dienste behalten ihre Bearbeitungssperre.
|
||||||
|
- Kindlöschung im Elternprofil und in der Familienverwaltung ist bei heutigen/zukünftigen veröffentlichten Notdiensten oder aktiven Alarmen gesperrt. Zuerst muss die Koordination die Einteilung klären. Prüfung und Löschung laufen in derselben serialisierbaren Transaktion mit einer Sperre der betroffenen Kinder.
|
||||||
|
- Kein Schema-Upgrade erforderlich. Automatische Erinnerungen und Benachrichtigungen bei Erstveröffentlichung sind weiterhin offen.
|
||||||
|
|
||||||
|
Prüfbefehle: `npm run test:notdienst` (Datumsregeln) und `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_notdienst_test npm run test:notdienst:integration` (echte Server Actions und PostgreSQL). Der Integrationsbefehl verlangt ausdrücklich eine separate lokale Testdatenbank; fehlende Konfiguration führt zu einem Fehler. Zuvor die vorhandenen Migrationen auf dieser Testdatenbank anwenden.
|
||||||
|
|
||||||
|
Abnahme: 4 Datumstests, 34 PostgreSQL-Regressionstests und 28 bestehende Sicherheitstests erfolgreich. Im Browser geprüft: gesperrter Teamtag/Wochenende, Speicherung des vollständigen Haushaltssolls, Veröffentlichungssperre, Entfernen einer alten ungültigen Einteilung und gültige manuelle Zuweisung mit anschließender Veröffentlichung. TypeScript und Produktionsbuild erfolgreich; projektweites ESLint ohne Fehler mit acht bestehenden Warnungen.
|
||||||
|
|
||||||
## Ergänzung vom 09.10.2026: Notdienstalarm
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Aktive Alarme sperren manuelle Umzuordnung und Diensttausche. Für einen Ersatz
|
|||||||
|
|
||||||
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.
|
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.
|
Der bestehende Docker-Produktionsstart startet `scripts/notdienst-alarm-worker.mjs`. Derselbe geschützte Job verarbeitet zusätzlich die gespeicherten Mitteilungs-Mails und deren Wiederholungen. Der Worker 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.
|
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.
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ HTTP 401 bedeutet fehlende/falsche Berechtigung, 503 fehlendes/zu kurzes Job-Sec
|
|||||||
|
|
||||||
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.
|
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).
|
Aktuelle Releases verwenden versionierte Prisma-Migrationen über den separaten Initialisierungsdienst. Die Einrichtung beschreibt [deploy/README.md](../deploy/README.md); `prisma db push` und die historischen SQL-Upgrades sind kein aktueller Betriebsweg. Der bestehende Hintergrundjob übernimmt zusätzlich Verfügbarkeitserinnerungen und gespeicherte Veröffentlichungsmails.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,9 @@ Parallele Änderungen laufen in serialisierbaren Datenbanktransaktionen mit Wied
|
|||||||
|
|
||||||
## Einführung und Schema
|
## 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.
|
Aktuelle Releases verwenden die versionierten Migrationen in `prisma/migrations` und einen separaten Initialisierungsdienst vor dem App-Start. Die [zentrale Betriebsanleitung](../deploy/README.md) beschreibt Installation, Updates, Baseline für bestehende Datenbanken und Runtime-Grants. `scripts/start-production.sh` startet die Anwendung; es verändert das Schema nicht. Kein `prisma db push` auf verwalteten Datenbanken verwenden.
|
||||||
|
|
||||||
- Lokal: Abhängigkeiten installieren, Prisma-Client generieren, Schema gegen eine dafür vorgesehene Datenbank anwenden, App starten und Profile einrichten.
|
Die historischen SQL-Dateien [Personalbereich](../deploy/sql/personalbereich.sql) und [Personalbereich-E-Mails](../deploy/sql/personalbereich-email.sql) dokumentieren ihre damaligen Ausgangsstände. Sie sind kein aktueller Deployment-Schritt und dürfen nicht zusätzlich zu den versionierten Migrationen ausgeführt werden. Vorhandene Personen erhalten weiterhin erst durch Einrichtung ein Beschäftigungsprofil.
|
||||||
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,9 @@
|
|||||||
|
|
||||||
## Bestehende Daten und Schemaaktualisierung
|
## 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.
|
Aktuelle Releases verwenden die versionierten Migrationen in `prisma/migrations`. Der separate Initialisierungsdienst wendet sie vor dem App-Start an und stellt die Runtime-Grants bereit. Einrichtung und Updates beschreibt [deploy/README.md](../deploy/README.md). Kein `prisma db push` auf verwalteten Datenbanken einsetzen: zusätzliche Tenant-Constraints ergänzen das Prisma-Modell.
|
||||||
|
|
||||||
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:
|
`prisma/updates/20261007_parent_flows.sql` dokumentiert den historischen Ausgangsstand vor Einführung der Migrationshistorie. Es gehört nicht zum aktuellen Deployment und darf nicht zusätzlich zu bereits angewandten Migrationen ausgeführt werden. Die kompatiblen Defaults für bisherige Termine (`CHILD`) und Meldungen (`ALLE`) sind in den versionierten Migrationen enthalten.
|
||||||
|
|
||||||
```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
|
## Verifikation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Produkt-Screenshots
|
||||||
|
|
||||||
|
Die Bilder in `public/landing/screenshots/` sind echte Browseraufnahmen der WebApp mit ausschließlich fiktiven Beispieldaten. Sie wurden am 13. Oktober 2026 aufgenommen. Keine Produktionsdaten, echten Kontaktadressen oder Zugangsdaten sind enthalten.
|
||||||
|
|
||||||
|
Die Metadaten und Bildbeschreibungen stehen in `src/lib/marketing/screenshots.ts`. Landingpage und Featureseiten verwenden dieselben Dateien. Die Vorschau reserviert den Bildbereich, lädt Bilder bedarfsgerecht über Next Image und bietet eine tastaturbedienbare Vollansicht mit Fensteranpassung, Originalgröße und Scrollbereich.
|
||||||
|
|
||||||
|
## Aufnahmen erneuern
|
||||||
|
|
||||||
|
1. Eine **leere**, ausschließlich lokale PostgreSQL-Datenbank namens `kita_screenshots_test` erstellen. Andere Datenbanknamen oder Hosts werden vom Fixture-Skript abgewiesen; bestehende Daten werden niemals gelöscht.
|
||||||
|
2. `DATABASE_URL` nur für diese Sitzung auf diese Datenbank setzen und `npm run db:migrate:deploy` ausführen.
|
||||||
|
3. Mit derselben URL `npx tsx scripts/seed-screenshots.ts` ausführen. Das Skript legt Kita Sonnenwinkel, sechs fiktive Familien und eine Fachkraft an. Die Zugangsdaten werden nur lokal ausgegeben. Alle E-Mail-Adressen verwenden die nicht zustellbare Domain `example.invalid`.
|
||||||
|
4. Die App mit einer eigenen lokalen Auth-URL und einem Test-Secret starten. E-Mail-Versand und Analytics deaktivieren (`RESEND_API_KEY`, `NEXT_PUBLIC_UMAMI_SCRIPT_URL` und `NEXT_PUBLIC_UMAMI_WEBSITE_ID` leer; öffentliche Variablen schon beim Build leer setzen). Keine Jobs starten.
|
||||||
|
5. Über die normale Anmeldung und Navigation die Ansichten aufnehmen. Der Fixture-Stand verwendet Oktober/November 2026. Bei späteren Aufnahmen die Termine, Auswahl und Arbeitswoche passend aktualisieren.
|
||||||
|
6. Browseraufnahmen als JPEG speichern. Die Elternansichten wurden bei 1600 × 1000, die Arbeitszeitansicht bei 1600 × 1280 aufgenommen. Der Browser kann beim Ausschließen der Scrollbar leicht abweichende tatsächliche Bildmaße liefern; die Metadaten immer aus den **gespeicherten Dateien** lesen und aktualisieren.
|
||||||
|
7. Notdienst, Kalender, Adressbuch und eigene Abwesenheiten zeigen die Elternansicht. Bei Mitteilungen die erste aktualisierte Meldung öffnen. Für Arbeitszeit mit dem Teamkonto anmelden und zur erfassten Woche vom 5. bis 11. Oktober wechseln.
|
||||||
|
8. Bilder und Vergrößerung auf Desktop und Mobilgerät prüfen, dann Beispieldatenbank und lokalen Server entfernen.
|
||||||
|
|
||||||
|
Die sechs Screenshot-Files der Galerie sind zusammen unter 500 KiB groß. Die zusätzliche Aufnahme `termin-details.jpg` zeigt Teilnahme und Mitbringliste für das Erklärvideo. Das alte Bild `notdienst-calendar-linen.png` wird nicht mehr von der Landingpage verwendet.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Erklärvideo für Kita-Planer
|
||||||
|
|
||||||
|
Stand: 14. Oktober 2026. Das Video ist ein zweiminütiger, tonloser Rundgang durch die WebApp. Es verwendet echte App-Screenshots mit ausschließlich fiktiven Beispieldaten. Bildausschnitte und grüne Markierungen führen durch die Funktionen; es wird keine Bildschirmaufnahme von Klicks vorgetäuscht.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
- `public/landing/video/kita-planer-rundgang.mp4`: teilbare Fassung mit fest eingeblendeten deutschen Untertiteln. H.264, 1920 × 1080, 25 Bilder/s, 2:00 Minuten, ohne Audiospur.
|
||||||
|
- `public/landing/video/kita-planer-rundgang-web.mp4`: Website-Fassung ohne eingebrannte Untertitel. Der native Videoplayer lädt standardmäßig die deutschen WebVTT-Untertitel; sie passen sich dem Player an und lassen sich abschalten.
|
||||||
|
- `kita-planer-untertitel.vtt`: synchronisierte deutsche Untertitel für beide Fassungen.
|
||||||
|
- `kita-planer-kapitel.vtt`: acht Kapitel mit Zeitmarken.
|
||||||
|
- `kita-planer-poster.jpg`: Vorschaubild für die Landingpage.
|
||||||
|
- `kita-planer-transkript.txt`: vollständige Textfassung.
|
||||||
|
|
||||||
|
Alle Dateien liegen unter `public/landing/video/`. Der Player startet erst auf Wunsch und lädt das Video nicht beim Seitenaufruf (`preload="none"`). Er nutzt keine externen Videoanbieter. Unter dem Player gibt es Downloadlinks und eine aufklappbare Textfassung.
|
||||||
|
|
||||||
|
## Erneut rendern
|
||||||
|
|
||||||
|
Das Storyboard in `scripts/product-video/storyboard.json` enthält Dauer, Texte, Quellen, Bildausschnitte und Markierungen jeder Szene. Die Quellen liegen in `public/landing/screenshots/`; die Terminaufnahme entstand ebenfalls mit dem isolierten Screenshot-Fixture. Anleitung siehe `product-screenshots.md`.
|
||||||
|
|
||||||
|
Mit installierten Projektabhängigkeiten und lokalem `ffmpeg`:
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/product-video/render.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Renderer verwendet `sharp` aus den installierten Next.js-Abhängigkeiten für SVG-Titel und Video-Compositing. Unter macOS wird die vorhandene Schrift Avenir Next verwendet. Auf anderen Systemen diese Schrift oder einen geeigneten Ersatz bereitstellen und die Umbrüche visuell prüfen. Rendern ist ein lokaler Vorbereitungsschritt und kein Teil des Produktionsbuilds; das Deployment liefert die fertigen Dateien aus.
|
||||||
|
|
||||||
|
Bei Textänderungen beide Fassungen rendern, den Film vollständig auf Lesbarkeit und Synchronität prüfen und die Kapitelstarts mit dem Storyboard abgleichen. Bei Aktualisierung der App die echten Screenshots erneuern und Ausschnitte/Markierungen anpassen. Die temporären Renderdateien werden nach dem Lauf automatisch entfernt.
|
||||||
@@ -24,7 +24,20 @@
|
|||||||
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
|
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
|
||||||
"test:alarms": "tsx --experimental-test-module-mocks --test tests/notdienst-alarm.test.ts",
|
"test:alarms": "tsx --experimental-test-module-mocks --test tests/notdienst-alarm.test.ts",
|
||||||
"test:parent-flows": "tsx --test tests/parent-flows.test.ts",
|
"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"
|
"test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts",
|
||||||
|
"test:notdienst": "tsx --test tests/notdienst-days.test.ts",
|
||||||
|
"test:notdienst:integration": "node --test tests/notdienst-planning.integration.test.cjs",
|
||||||
|
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
|
||||||
|
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
|
||||||
|
"test:announcements": "node --test tests/announcements.integration.test.cjs",
|
||||||
|
"test:seo:http": "node --test tests/marketing-seo-http.cjs",
|
||||||
|
"test:seo": "node --test tests/marketing-seo.test.cjs",
|
||||||
|
"test:mail": "node --test tests/mail-transport.test.cjs",
|
||||||
|
"test:account": "node --test tests/password-management.integration.test.cjs",
|
||||||
|
"test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs",
|
||||||
|
"test:runtime": "node --test tests/runtime-supervisor.test.cjs",
|
||||||
|
"test:family-absence": "tsx --test tests/absence-policy.test.ts",
|
||||||
|
"test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Family invitation delivery status; applied before the restricted runtime starts.
|
||||||
|
ALTER TABLE "invitations"
|
||||||
|
ADD COLUMN "userId" TEXT,
|
||||||
|
ADD COLUMN "sentAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "lastAttemptAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "leaseUntil" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "failed" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
CREATE UNIQUE INDEX "invitations_userId_key" ON "invitations"("userId");
|
||||||
|
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_userId_tenant_fkey"
|
||||||
|
FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE "notdienst_emails" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kitaId" TEXT NOT NULL,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"planId" TEXT,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"month" INTEGER NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"eventKey" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"skippedAt" TIMESTAMP(3),
|
||||||
|
"leaseUntil" TIMESTAMP(3),
|
||||||
|
"claimToken" TEXT,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
CONSTRAINT "notdienst_emails_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "notdienst_emails_month_check" CHECK ("month" BETWEEN 1 AND 12),
|
||||||
|
CONSTRAINT "notdienst_emails_kind_check" CHECK ("kind" IN ('REMINDER','PUBLICATION')),
|
||||||
|
CONSTRAINT "notdienst_emails_plan_check" CHECK (("kind" = 'PUBLICATION' AND "planId" IS NOT NULL) OR ("kind" = 'REMINDER' AND "planId" IS NULL))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "notdienst_emails_kitaId_userId_kind_year_month_eventKey_key" ON "notdienst_emails"("kitaId","userId","kind","year","month","eventKey");
|
||||||
|
CREATE INDEX "notdienst_emails_sentAt_skippedAt_nextAttemptAt_idx" ON "notdienst_emails"("sentAt","skippedAt","nextAttemptAt");
|
||||||
|
CREATE INDEX "notdienst_emails_kitaId_year_month_idx" ON "notdienst_emails"("kitaId","year","month");
|
||||||
|
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_planId_fkey" FOREIGN KEY ("planId") REFERENCES "notdienst_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_familyId_tenant_fkey" FOREIGN KEY ("familyId","kitaId") REFERENCES "families"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_userId_tenant_fkey" FOREIGN KEY ("userId","kitaId") REFERENCES "users"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "notdienst_emails" ADD CONSTRAINT "notdienst_emails_planId_tenant_fkey" FOREIGN KEY ("planId","kitaId") REFERENCES "notdienst_plans"("id","kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
CREATE TABLE "notdienst_reminder_runs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kitaId" TEXT NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"month" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "notdienst_reminder_runs_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "notdienst_reminder_runs_month_check" CHECK ("month" BETWEEN 1 AND 12)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "notdienst_reminder_runs_kitaId_year_month_key" ON "notdienst_reminder_runs"("kitaId","year","month");
|
||||||
|
ALTER TABLE "notdienst_reminder_runs" ADD CONSTRAINT "notdienst_reminder_runs_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE "announcements" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN "editedAt" TIMESTAMP(3), ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ADD COLUMN "emailReady" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
UPDATE "announcements" SET "updatedAt" = "createdAt";
|
||||||
|
CREATE INDEX "announcements_kitaId_updatedAt_idx" ON "announcements"("kitaId", "updatedAt");
|
||||||
|
CREATE TABLE "announcement_emails" (
|
||||||
|
"id" TEXT NOT NULL, "kitaId" TEXT NOT NULL, "announcementId" TEXT NOT NULL, "userId" TEXT NOT NULL,
|
||||||
|
"revision" INTEGER NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"leaseUntil" TIMESTAMP(3), "claimToken" TEXT, "sentAt" TIMESTAMP(3), "skippedAt" TIMESTAMP(3),
|
||||||
|
"failed" BOOLEAN NOT NULL DEFAULT false, "lastError" TEXT,
|
||||||
|
CONSTRAINT "announcement_emails_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "announcement_emails_revision_check" CHECK ("revision" > 0)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "announcement_emails_announcementId_revision_userId_key" ON "announcement_emails"("announcementId", "revision", "userId");
|
||||||
|
CREATE INDEX "announcement_emails_kitaId_announcementId_revision_idx" ON "announcement_emails"("kitaId", "announcementId", "revision");
|
||||||
|
CREATE INDEX "announcement_emails_sentAt_skippedAt_nextAttemptAt_idx" ON "announcement_emails"("sentAt", "skippedAt", "nextAttemptAt");
|
||||||
|
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_announcementId_tenant_fkey" FOREIGN KEY ("announcementId", "kitaId") REFERENCES "announcements"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "announcement_emails" ADD CONSTRAINT "announcement_emails_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
COMMIT;
|
||||||
@@ -155,10 +155,13 @@ model Kita {
|
|||||||
dutyAssignments DutyAssignment[]
|
dutyAssignments DutyAssignment[]
|
||||||
absences Absence[]
|
absences Absence[]
|
||||||
announcements Announcement[]
|
announcements Announcement[]
|
||||||
|
announcementEmails AnnouncementEmail[]
|
||||||
invitations Invitation[]
|
invitations Invitation[]
|
||||||
termine Termin[]
|
termine Termin[]
|
||||||
mitbringselItems MitbringselItem[]
|
mitbringselItems MitbringselItem[]
|
||||||
eventParticipations EventParticipation[]
|
eventParticipations EventParticipation[]
|
||||||
|
notdienstEmails NotdienstEmail[]
|
||||||
|
notdienstReminderRuns NotdienstReminderRun[]
|
||||||
notdienstPlans NotdienstPlan[]
|
notdienstPlans NotdienstPlan[]
|
||||||
notdienstAvailabilities NotdienstAvailability[]
|
notdienstAvailabilities NotdienstAvailability[]
|
||||||
notdienstAssignments NotdienstAssignment[]
|
notdienstAssignments NotdienstAssignment[]
|
||||||
@@ -184,6 +187,7 @@ model Family {
|
|||||||
|
|
||||||
name String
|
name String
|
||||||
notdienstAlarms NotdienstAlarm[]
|
notdienstAlarms NotdienstAlarm[]
|
||||||
|
notdienstEmails NotdienstEmail[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -268,6 +272,7 @@ model User {
|
|||||||
|
|
||||||
mitbringselItems MitbringselItem[]
|
mitbringselItems MitbringselItem[]
|
||||||
invitationsCreated Invitation[] @relation("InvitationCreator")
|
invitationsCreated Invitation[] @relation("InvitationCreator")
|
||||||
|
familyInvitation Invitation? @relation("InvitationRecipient")
|
||||||
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
||||||
staffProfile StaffProfile?
|
staffProfile StaffProfile?
|
||||||
staffNotifications StaffNotification[]
|
staffNotifications StaffNotification[]
|
||||||
@@ -275,6 +280,8 @@ model User {
|
|||||||
alarmRecipients NotdienstAlarmRecipient[]
|
alarmRecipients NotdienstAlarmRecipient[]
|
||||||
alarmNotifications NotdienstAlarmNotification[]
|
alarmNotifications NotdienstAlarmNotification[]
|
||||||
alarmEmails NotdienstAlarmEmail[]
|
alarmEmails NotdienstAlarmEmail[]
|
||||||
|
notdienstEmails NotdienstEmail[]
|
||||||
|
announcementEmails AnnouncementEmail[]
|
||||||
|
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@index([familyId])
|
@@index([familyId])
|
||||||
@@ -692,6 +699,10 @@ model Announcement {
|
|||||||
title String
|
title String
|
||||||
content String
|
content String
|
||||||
audience AnnouncementAudience @default(ALLE)
|
audience AnnouncementAudience @default(ALLE)
|
||||||
|
revision Int @default(1)
|
||||||
|
editedAt DateTime?
|
||||||
|
updatedAt DateTime @default(now())
|
||||||
|
emailReady Boolean @default(true)
|
||||||
|
|
||||||
authorId String
|
authorId String
|
||||||
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
@@ -700,13 +711,40 @@ model Announcement {
|
|||||||
|
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
reads AnnouncementRead[]
|
reads AnnouncementRead[]
|
||||||
|
emails AnnouncementEmail[]
|
||||||
|
|
||||||
@@index([kitaId, createdAt])
|
@@index([kitaId, createdAt])
|
||||||
|
@@index([kitaId, updatedAt])
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@unique([id, kitaId])
|
@@unique([id, kitaId])
|
||||||
@@map("announcements")
|
@@map("announcements")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AnnouncementEmail {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
announcementId String
|
||||||
|
announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
revision Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
attempts Int @default(0)
|
||||||
|
leaseUntil DateTime?
|
||||||
|
claimToken String?
|
||||||
|
sentAt DateTime?
|
||||||
|
skippedAt DateTime?
|
||||||
|
failed Boolean @default(false)
|
||||||
|
lastError String?
|
||||||
|
|
||||||
|
@@unique([announcementId, revision, userId])
|
||||||
|
@@index([kitaId, announcementId, revision])
|
||||||
|
@@index([sentAt, skippedAt, nextAttemptAt])
|
||||||
|
@@map("announcement_emails")
|
||||||
|
}
|
||||||
|
|
||||||
model Attachment {
|
model Attachment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
announcementId String
|
announcementId String
|
||||||
@@ -749,10 +787,19 @@ model Invitation {
|
|||||||
email String
|
email String
|
||||||
role UserRole @default(ELTERN)
|
role UserRole @default(ELTERN)
|
||||||
|
|
||||||
|
// Nullable for older, unlinked invitations. Family invitations belong to a user.
|
||||||
|
userId String? @unique
|
||||||
|
user User? @relation("InvitationRecipient", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
token String @unique
|
token String @unique
|
||||||
status InvitationStatus @default(PENDING)
|
status InvitationStatus @default(PENDING)
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
acceptedAt DateTime?
|
acceptedAt DateTime?
|
||||||
|
sentAt DateTime?
|
||||||
|
lastAttemptAt DateTime?
|
||||||
|
leaseUntil DateTime?
|
||||||
|
attempts Int @default(0)
|
||||||
|
failed Boolean @default(false)
|
||||||
|
|
||||||
invitedById String?
|
invitedById String?
|
||||||
invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull)
|
invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull)
|
||||||
@@ -902,6 +949,7 @@ model NotdienstPlan {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
assignments NotdienstAssignment[]
|
assignments NotdienstAssignment[]
|
||||||
|
emails NotdienstEmail[]
|
||||||
|
|
||||||
@@unique([kitaId, year, month])
|
@@unique([kitaId, year, month])
|
||||||
@@index([kitaId])
|
@@index([kitaId])
|
||||||
@@ -1208,3 +1256,46 @@ model PendingRegistration {
|
|||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@@map("pending_registrations")
|
@@map("pending_registrations")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Durable notification jobs. No bearer tokens or personal response links.
|
||||||
|
model NotdienstEmail {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
familyId String
|
||||||
|
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
planId String?
|
||||||
|
plan NotdienstPlan? @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
|
year Int
|
||||||
|
month Int
|
||||||
|
kind String
|
||||||
|
eventKey String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
sentAt DateTime?
|
||||||
|
skippedAt DateTime?
|
||||||
|
leaseUntil DateTime?
|
||||||
|
claimToken String?
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
failed Boolean @default(false)
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastError String?
|
||||||
|
|
||||||
|
@@unique([kitaId, userId, kind, year, month, eventKey])
|
||||||
|
@@index([sentAt, skippedAt, nextAttemptAt])
|
||||||
|
@@index([kitaId, year, month])
|
||||||
|
@@map("notdienst_emails")
|
||||||
|
}
|
||||||
|
|
||||||
|
model NotdienstReminderRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kitaId String
|
||||||
|
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||||
|
year Int
|
||||||
|
month Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([kitaId, year, month])
|
||||||
|
@@map("notdienst_reminder_runs")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
-- HISTORISCHER UPGRADEPFAD: aktuelle Releases verwenden prisma/migrations und deploy/README.md.
|
||||||
|
-- Nicht zusätzlich zu versionierten Migrationen oder auf bereits aktualisierten Datenbanken ausführen.
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
WEBVTT
|
||||||
|
|
||||||
|
1
|
||||||
|
00:00:00.000 --> 00:00:10.000
|
||||||
|
Willkommen im Kita-Planer
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:10.000 --> 00:00:32.000
|
||||||
|
Notdienst gemeinsam planen
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:32.000 --> 00:00:52.000
|
||||||
|
Termine und Feste im Blick
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:52.000 --> 00:01:06.000
|
||||||
|
Freigegebene Kontakte
|
||||||
|
|
||||||
|
5
|
||||||
|
00:01:06.000 --> 00:01:20.000
|
||||||
|
Abwesenheiten verwalten
|
||||||
|
|
||||||
|
6
|
||||||
|
00:01:20.000 --> 00:01:34.000
|
||||||
|
Offizielle Mitteilungen
|
||||||
|
|
||||||
|
7
|
||||||
|
00:01:34.000 --> 00:01:50.000
|
||||||
|
Arbeitszeit für das Team
|
||||||
|
|
||||||
|
8
|
||||||
|
00:01:50.000 --> 00:02:00.000
|
||||||
|
Kita-Planer kennenlernen
|
||||||
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
Kita-Planer – Ein Rundgang durch die Anwendung
|
||||||
|
Dauer: 120 Sekunden. Ohne Ton. App-Ansichten mit fiktiven Beispieldaten.
|
||||||
|
|
||||||
|
Willkommen im Kita-Planer
|
||||||
|
Kita-Planer bringt Dienste, Termine und offizielle Informationen an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team.
|
||||||
|
|
||||||
|
Notdienst gemeinsam planen
|
||||||
|
Eltern markieren die Tage, an denen ihr Haushalt für den Notdienst verfügbar ist.
|
||||||
|
|
||||||
|
Notdienst gemeinsam planen
|
||||||
|
Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender. So lässt sich die Auswahl mit dem Kita-Alltag abstimmen.
|
||||||
|
|
||||||
|
Notdienst gemeinsam planen
|
||||||
|
Die Anzeige zeigt, ob genug Tage ausgewählt sind. Mit Speichern wird die Verfügbarkeit für die Planung übernommen.
|
||||||
|
|
||||||
|
Termine und Feste im Blick
|
||||||
|
Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage. Alle sehen die Termine im selben Zusammenhang.
|
||||||
|
|
||||||
|
Termine und Feste im Blick
|
||||||
|
Für dieses Herbstfest antwortet ihr pro Familie und gebt an, wie viele Erwachsene teilnehmen.
|
||||||
|
|
||||||
|
Termine und Feste im Blick
|
||||||
|
In der Mitbringliste seht ihr, wer schon etwas beisteuert. Eigene Beiträge könnt ihr bearbeiten oder löschen.
|
||||||
|
|
||||||
|
Freigegebene Kontakte
|
||||||
|
Das interne Adressbuch verbindet Haushalte, Kinder und die freigegebenen Kontakte der Eltern.
|
||||||
|
|
||||||
|
Freigegebene Kontakte
|
||||||
|
Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten. Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben.
|
||||||
|
|
||||||
|
Abwesenheiten verwalten
|
||||||
|
Eltern melden Abwesenheiten digital. Die Kita sieht die Meldung in ihrer Übersicht.
|
||||||
|
|
||||||
|
Abwesenheiten verwalten
|
||||||
|
Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen. Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung.
|
||||||
|
|
||||||
|
Offizielle Mitteilungen
|
||||||
|
Mitteilungen bündeln offizielle Informationen an einem Ort. Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar.
|
||||||
|
|
||||||
|
Offizielle Mitteilungen
|
||||||
|
Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar. Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar.
|
||||||
|
|
||||||
|
Arbeitszeit für das Team
|
||||||
|
Fachkräfte erfassen ihre Arbeitszeit mit Pausen und reichen vollständige Wochen zur Prüfung ein.
|
||||||
|
|
||||||
|
Arbeitszeit für das Team
|
||||||
|
Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar. Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen.
|
||||||
|
|
||||||
|
Kita-Planer kennenlernen
|
||||||
|
Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen. Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
WEBVTT
|
||||||
|
|
||||||
|
1
|
||||||
|
00:00:00.000 --> 00:00:10.000
|
||||||
|
Kita-Planer bringt Dienste, Termine und offizielle Informationen
|
||||||
|
an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:10.000 --> 00:00:17.000
|
||||||
|
Eltern markieren die Tage, an denen ihr Haushalt
|
||||||
|
für den Notdienst verfügbar ist.
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:17.000 --> 00:00:24.000
|
||||||
|
Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender.
|
||||||
|
So lässt sich die Auswahl mit dem Kita-Alltag abstimmen.
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:24.000 --> 00:00:32.000
|
||||||
|
Die Anzeige zeigt, ob genug Tage ausgewählt sind.
|
||||||
|
Mit Speichern wird die Verfügbarkeit für die Planung übernommen.
|
||||||
|
|
||||||
|
5
|
||||||
|
00:00:32.000 --> 00:00:41.000
|
||||||
|
Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage.
|
||||||
|
Alle sehen die Termine im selben Zusammenhang.
|
||||||
|
|
||||||
|
6
|
||||||
|
00:00:41.000 --> 00:00:46.000
|
||||||
|
Für dieses Herbstfest antwortet ihr pro Familie
|
||||||
|
und gebt an, wie viele Erwachsene teilnehmen.
|
||||||
|
|
||||||
|
7
|
||||||
|
00:00:46.000 --> 00:00:52.000
|
||||||
|
In der Mitbringliste seht ihr, wer schon etwas beisteuert.
|
||||||
|
Eigene Beiträge könnt ihr bearbeiten oder löschen.
|
||||||
|
|
||||||
|
8
|
||||||
|
00:00:52.000 --> 00:00:59.000
|
||||||
|
Das interne Adressbuch verbindet Haushalte, Kinder
|
||||||
|
und die freigegebenen Kontakte der Eltern.
|
||||||
|
|
||||||
|
9
|
||||||
|
00:00:59.000 --> 00:01:06.000
|
||||||
|
Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten.
|
||||||
|
Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben.
|
||||||
|
|
||||||
|
10
|
||||||
|
00:01:06.000 --> 00:01:13.000
|
||||||
|
Eltern melden Abwesenheiten digital.
|
||||||
|
Die Kita sieht die Meldung in ihrer Übersicht.
|
||||||
|
|
||||||
|
11
|
||||||
|
00:01:13.000 --> 00:01:20.000
|
||||||
|
Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen.
|
||||||
|
Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung.
|
||||||
|
|
||||||
|
12
|
||||||
|
00:01:20.000 --> 00:01:27.000
|
||||||
|
Mitteilungen bündeln offizielle Informationen an einem Ort.
|
||||||
|
Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar.
|
||||||
|
|
||||||
|
13
|
||||||
|
00:01:27.000 --> 00:01:34.000
|
||||||
|
Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar.
|
||||||
|
Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar.
|
||||||
|
|
||||||
|
14
|
||||||
|
00:01:34.000 --> 00:01:42.000
|
||||||
|
Fachkräfte erfassen ihre Arbeitszeit mit Pausen
|
||||||
|
und reichen vollständige Wochen zur Prüfung ein.
|
||||||
|
|
||||||
|
15
|
||||||
|
00:01:42.000 --> 00:01:50.000
|
||||||
|
Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar.
|
||||||
|
Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen.
|
||||||
|
|
||||||
|
16
|
||||||
|
00:01:50.000 --> 00:02:00.000
|
||||||
|
Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen.
|
||||||
|
Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten.
|
||||||
@@ -59,4 +59,15 @@ UNION ALL
|
|||||||
SELECT 'notdienst_alerts.educatorId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "educators" p ON c."educatorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
SELECT 'notdienst_alerts.educatorId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "educators" p ON c."educatorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'announcement_reads.userId' AS relation, count(*) AS invalid_rows FROM announcement_reads r JOIN announcements a ON r."announcementId" = a.id JOIN users u ON r."userId" = u.id WHERE u."kitaId" IS DISTINCT FROM a."kitaId"
|
SELECT 'announcement_reads.userId' AS relation, count(*) AS invalid_rows FROM announcement_reads r JOIN announcements a ON r."announcementId" = a.id JOIN users u ON r."userId" = u.id WHERE u."kitaId" IS DISTINCT FROM a."kitaId"
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_emails.familyId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_emails.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notdienst_emails.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_emails" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'announcement_emails.userId' AS relation, count(*) AS invalid_rows FROM "announcement_emails" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'announcement_emails.announcementId' AS relation, count(*) AS invalid_rows FROM "announcement_emails" c JOIN "announcements" p ON c."announcementId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
|
||||||
) violations WHERE invalid_rows > 0;
|
) violations WHERE invalid_rows > 0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
const secret = process.env.NOTDIENST_JOB_SECRET;
|
const secret = process.env.NOTDIENST_JOB_SECRET;
|
||||||
if (!secret || secret.length < 32) {
|
if (!secret || secret.length < 32) {
|
||||||
console.error(
|
console.error(
|
||||||
"Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
|
"Notdienst-Hintergrundjob deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -23,11 +23,11 @@ async function tick() {
|
|||||||
);
|
);
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
console.error(
|
console.error(
|
||||||
`Notdienst-Fristprüfung: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
|
`Notdienst-Hintergrundjob: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(
|
console.error(
|
||||||
"Notdienst-Fristprüfung momentan nicht erreichbar; erneuter Versuch in einer Minute.",
|
"Notdienst-Hintergrundjob momentan nicht erreichbar; erneuter Versuch in einer Minute.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!stopped)
|
if (!stopped)
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ cpSync('scripts/start-production.sh', '.next/standalone/scripts/start-production
|
|||||||
cpSync('scripts/notdienst-alarm-worker.mjs', '.next/standalone/scripts/notdienst-alarm-worker.mjs');
|
cpSync('scripts/notdienst-alarm-worker.mjs', '.next/standalone/scripts/notdienst-alarm-worker.mjs');
|
||||||
cpSync('scripts/runtime-database-url.mjs', '.next/standalone/scripts/runtime-database-url.mjs');
|
cpSync('scripts/runtime-database-url.mjs', '.next/standalone/scripts/runtime-database-url.mjs');
|
||||||
cpSync('scripts/verify-runtime-isolation.mjs', '.next/standalone/scripts/verify-runtime-isolation.mjs');
|
cpSync('scripts/verify-runtime-isolation.mjs', '.next/standalone/scripts/verify-runtime-isolation.mjs');
|
||||||
|
|
||||||
|
cpSync('scripts/start-runtime.mjs', '.next/standalone/scripts/start-runtime.mjs');
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/** Render the local, silent product tour from real app captures and SVG titles. */
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const storyboard = JSON.parse(await fs.readFile(path.join(root, 'scripts/product-video/storyboard.json'), 'utf8'));
|
||||||
|
const out = path.join(root, 'public/landing/video');
|
||||||
|
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'kita-product-video-'));
|
||||||
|
await fs.mkdir(out, { recursive: true });
|
||||||
|
const { width: W, height: H, fps } = storyboard;
|
||||||
|
const colors = { paper: '#f7f4ed', ink: '#302820', brown: '#7b492e', green: '#3f745a', muted: '#696358' };
|
||||||
|
const esc = text => String(text).replace(/[<>&"']/g, c => ({ '<':'<', '>':'>', '&':'&', '"':'"', "'":''' }[c]));
|
||||||
|
const text = (x,y,value,size=30,color=colors.ink,weight=400) => `<text x="${x}" y="${y}" font-family="Avenir Next, sans-serif" font-size="${size}" font-weight="${weight}" fill="${color}">${esc(value)}</text>`;
|
||||||
|
const lines = (x,y,values,size=30,step=44,color=colors.ink,weight=400) => values.map((v,i)=>text(x,y+i*step,v,size,color,weight)).join('');
|
||||||
|
const imageStage = { x:600, y:162, width:1240, height:714 };
|
||||||
|
const total = storyboard.scenes.reduce((sum,scene)=>sum+scene.duration,0);
|
||||||
|
const timestamp = seconds => `${String(Math.floor(seconds/3600)).padStart(2,'0')}:${String(Math.floor(seconds/60)%60).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}.000`;
|
||||||
|
let time = 0;
|
||||||
|
const cues = [];
|
||||||
|
const assets = [];
|
||||||
|
const webAssets = [];
|
||||||
|
|
||||||
|
function ffmpeg(args) {
|
||||||
|
const result = spawnSync('ffmpeg', ['-hide_banner','-loglevel','error','-y',...args], { encoding:'utf8', maxBuffer:8*1024*1024 });
|
||||||
|
if (result.status !== 0) throw new Error(result.stderr || 'ffmpeg failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [index,scene] of storyboard.scenes.entries()) {
|
||||||
|
const [left,top,width,height] = scene.crop;
|
||||||
|
const shot = await sharp(path.join(root, 'public/landing/screenshots',scene.image))
|
||||||
|
.extract({left,top,width,height}).resize(imageStage.width,imageStage.height,{fit:'contain',background:colors.paper}).png().toBuffer();
|
||||||
|
const scale = Math.min(imageStage.width/width,imageStage.height/height);
|
||||||
|
const offsetX = imageStage.x+(imageStage.width-width*scale)/2;
|
||||||
|
const offsetY = imageStage.y+(imageStage.height-height*scale)/2;
|
||||||
|
let focus = '';
|
||||||
|
if (scene.focus) {
|
||||||
|
const [fx,fy,fw,fh] = scene.focus;
|
||||||
|
focus = `<rect x="${offsetX+(fx-left)*scale}" y="${offsetY+(fy-top)*scale}" width="${fw*scale}" height="${fh*scale}" rx="12" fill="none" stroke="${colors.green}" stroke-width="5"/><circle cx="${offsetX+(fx-left)*scale+12}" cy="${offsetY+(fy-top)*scale-10}" r="18" fill="${colors.green}"/>`;
|
||||||
|
}
|
||||||
|
const chapter = storyboard.chapters[scene.chapter];
|
||||||
|
const title = lines(74,290,scene.title,56,72,colors.ink,600);
|
||||||
|
const hintWords = scene.hint.split(' '); const hintLines = []; let line = '';
|
||||||
|
for (const word of hintWords) { if ((line+' '+word).trim().length > 28) { hintLines.push(line); line=word; } else line=(line+' '+word).trim(); } if(line)hintLines.push(line);
|
||||||
|
const header = `<rect width="${W}" height="${H}" fill="${colors.paper}"/><rect x="72" y="44" width="54" height="54" rx="16" fill="${colors.brown}"/>${text(88,82,'K',30,'white',600)}${text(144,80,'Kita-Planer',32,colors.ink,600)}${text(144,111,'Ein Rundgang durch die Anwendung',19,colors.muted)}${text(1490,80,`${String(scene.chapter+1).padStart(2,'0')} / 08`,24,colors.green,600)}${text(1490,111,'App-Ansichten mit Beispieldaten',18,colors.muted)}<line x1="72" y1="132" x2="1840" y2="132" stroke="#dcd6cb"/>${text(74,208,scene.chapter===0?'WILLKOMMEN':scene.chapter===7?'GEMEINSAM STARTEN':`BEREICH ${String(scene.chapter).padStart(2,'0')}`,20,colors.green,600)}${title}<rect x="74" y="466" width="4" height="${Math.max(90,hintLines.length*40)}" rx="2" fill="${colors.green}"/>${lines(96,498,hintLines,28,40,colors.muted)}${text(74,775,'Für Eltern, Vorstand',24,colors.muted)}${text(74,811,'und das Kita-Team.',24,colors.muted)}<rect x="598" y="160" width="1244" height="718" rx="16" fill="white" stroke="#d9d3c7" stroke-width="2"/>`;
|
||||||
|
const footer = `<rect x="0" y="920" width="1920" height="160" fill="#ebe7dc"/><rect x="72" y="923" width="1768" height="4" rx="2" fill="#d8d1c2"/><rect x="72" y="923" width="${1768*(time+scene.duration)/total}" height="4" rx="2" fill="${colors.green}"/>${text(74,899,chapter.title,20,colors.muted)}${text(1470,899,'mein-kitaplaner.de',20,colors.brown,600)}`;
|
||||||
|
const base = await sharp(Buffer.from(`<svg width="${W}" height="${H}">${header}${footer}</svg>`)).png().toBuffer();
|
||||||
|
const captions = `<rect x="210" y="951" width="1500" height="100" rx="18" fill="#302820"/>${scene.subtitle.map((value,i)=>`<text x="960" y="${989+i*42}" text-anchor="middle" font-family="Avenir Next, sans-serif" font-size="34" font-weight="500" fill="#ffffff">${esc(value)}</text>`).join('')}`;
|
||||||
|
const foreground = Buffer.from(`<svg width="${W}" height="${H}">${focus}${captions}</svg>`);
|
||||||
|
const cleanFrame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:Buffer.from(`<svg width="${W}" height="${H}">${focus}</svg>`),left:0,top:0}]).png().toBuffer();
|
||||||
|
const cleanPath = path.join(temp,`web-frame-${index}.png`);
|
||||||
|
await fs.writeFile(cleanPath,cleanFrame);
|
||||||
|
const frame = await sharp(base).composite([{input:shot,left:imageStage.x,top:imageStage.y},{input:foreground,left:0,top:0}]).png().toBuffer();
|
||||||
|
const framePath = path.join(temp,`frame-${index}.png`);
|
||||||
|
await fs.writeFile(framePath,frame);
|
||||||
|
if(index===0) await sharp(frame).jpeg({quality:88}).toFile(path.join(out,'kita-planer-poster.jpg'));
|
||||||
|
const clip = path.join(temp,`clip-${index}.mp4`);
|
||||||
|
// Gentle transitions leave the UI and all captions still and legible.
|
||||||
|
ffmpeg(['-loop','1','-framerate',String(fps),'-i',framePath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',clip]);
|
||||||
|
assets.push(clip);
|
||||||
|
const webClip = path.join(temp,`web-clip-${index}.mp4`);
|
||||||
|
ffmpeg(['-loop','1','-framerate',String(fps),'-i',cleanPath,'-t',String(scene.duration),'-vf',`fade=t=in:st=0:d=0.25:color=0xf7f4ed,fade=t=out:st=${scene.duration-0.25}:d=0.25:color=0xf7f4ed,format=yuv420p`,'-c:v','libx264','-preset','fast','-crf','20','-threads','2','-an',webClip]);
|
||||||
|
webAssets.push(webClip);
|
||||||
|
cues.push(`${index+1}\n${timestamp(time)} --> ${timestamp(time+scene.duration)}\n${scene.subtitle.join('\n')}`);
|
||||||
|
time+=scene.duration;
|
||||||
|
console.log(`Rendered scene ${index+1}/${storyboard.scenes.length}: ${chapter.title}`);
|
||||||
|
}
|
||||||
|
await fs.writeFile(path.join(temp,'concat.txt'),assets.map(p=>`file '${p}'`).join('\n'));
|
||||||
|
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'concat.txt'),'-c','copy','-movflags','+faststart','-metadata','title=Kita-Planer – Ein Rundgang durch die Anwendung','-metadata','comment=Echte App-Ansichten mit fiktiven Beispieldaten. Deutsche Untertitel und visuelle Hinweise. Ohne Ton.',path.join(out,'kita-planer-rundgang.mp4')]);
|
||||||
|
await fs.writeFile(path.join(temp,'web-concat.txt'),webAssets.map(p=>`file '${p}'`).join('\n'));
|
||||||
|
ffmpeg(['-f','concat','-safe','0','-i',path.join(temp,'web-concat.txt'),'-c','copy','-movflags','+faststart',path.join(out,'kita-planer-rundgang-web.mp4')]);
|
||||||
|
await fs.writeFile(path.join(out,'kita-planer-untertitel.vtt'),`WEBVTT\n\n${cues.join('\n\n')}\n`);
|
||||||
|
await fs.writeFile(path.join(out,'kita-planer-kapitel.vtt'),`WEBVTT\n\n${storyboard.chapters.map((chapter,index)=>`${index+1}\n${timestamp(chapter.start)} --> ${timestamp(storyboard.chapters[index+1]?.start??total)}\n${chapter.title}`).join('\n\n')}\n`);
|
||||||
|
await fs.writeFile(path.join(out,'kita-planer-transkript.txt'),`Kita-Planer – Ein Rundgang durch die Anwendung\nDauer: ${total} Sekunden. Ohne Ton. App-Ansichten mit fiktiven Beispieldaten.\n\n${storyboard.scenes.map(scene=>`${storyboard.chapters[scene.chapter].title}\n${scene.subtitle.join(' ')}`).join('\n\n')}\n`);
|
||||||
|
console.log(`Finished ${total}s video in ${out}`);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(temp,{recursive:true,force:true});
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
{
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"fps": 25,
|
||||||
|
"chapters": [
|
||||||
|
{
|
||||||
|
"start": 0,
|
||||||
|
"title": "Willkommen im Kita-Planer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 10,
|
||||||
|
"title": "Notdienst gemeinsam planen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 32,
|
||||||
|
"title": "Termine und Feste im Blick"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 52,
|
||||||
|
"title": "Freigegebene Kontakte"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 66,
|
||||||
|
"title": "Abwesenheiten verwalten"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 80,
|
||||||
|
"title": "Offizielle Mitteilungen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 94,
|
||||||
|
"title": "Arbeitszeit für das Team"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": 110,
|
||||||
|
"title": "Kita-Planer kennenlernen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{
|
||||||
|
"duration": 10,
|
||||||
|
"chapter": 0,
|
||||||
|
"title": [
|
||||||
|
"Mehr Ruhe im",
|
||||||
|
"Kita-Alltag."
|
||||||
|
],
|
||||||
|
"hint": "Für Elterninitiativen und Kita-Vereine",
|
||||||
|
"image": "notdienst.jpg",
|
||||||
|
"crop": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1600,
|
||||||
|
1000
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Kita-Planer bringt Dienste, Termine und offizielle Informationen",
|
||||||
|
"an einen gemeinsamen Ort – für Eltern, Vorstand und Kita-Team."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 1,
|
||||||
|
"title": [
|
||||||
|
"Wann könnt",
|
||||||
|
"ihr helfen?"
|
||||||
|
],
|
||||||
|
"hint": "Verfügbarkeiten gemeinsam eintragen",
|
||||||
|
"image": "notdienst.jpg",
|
||||||
|
"crop": [
|
||||||
|
310,
|
||||||
|
45,
|
||||||
|
1230,
|
||||||
|
870
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
325,
|
||||||
|
365,
|
||||||
|
394,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Eltern markieren die Tage, an denen ihr Haushalt",
|
||||||
|
"für den Notdienst verfügbar ist."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 1,
|
||||||
|
"title": [
|
||||||
|
"Kita-Termine",
|
||||||
|
"gleich sehen."
|
||||||
|
],
|
||||||
|
"hint": "Teamtage sind für die Auswahl gesperrt",
|
||||||
|
"image": "notdienst.jpg",
|
||||||
|
"crop": [
|
||||||
|
330,
|
||||||
|
170,
|
||||||
|
1200,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
355,
|
||||||
|
670,
|
||||||
|
335,
|
||||||
|
105
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Feste, Elternabende und gesperrte Teamtage stehen direkt im Kalender.",
|
||||||
|
"So lässt sich die Auswahl mit dem Kita-Alltag abstimmen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 8,
|
||||||
|
"chapter": 1,
|
||||||
|
"title": [
|
||||||
|
"Auswahl prüfen.",
|
||||||
|
"Dann speichern."
|
||||||
|
],
|
||||||
|
"hint": "Der Fortschritt bleibt sichtbar",
|
||||||
|
"image": "notdienst.jpg",
|
||||||
|
"crop": [
|
||||||
|
310,
|
||||||
|
45,
|
||||||
|
1230,
|
||||||
|
870
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
1050,
|
||||||
|
80,
|
||||||
|
450,
|
||||||
|
65
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Die Anzeige zeigt, ob genug Tage ausgewählt sind.",
|
||||||
|
"Mit Speichern wird die Verfügbarkeit für die Planung übernommen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 9,
|
||||||
|
"chapter": 2,
|
||||||
|
"title": [
|
||||||
|
"Was steht",
|
||||||
|
"als Nächstes an?"
|
||||||
|
],
|
||||||
|
"hint": "Ein gemeinsamer Kalender",
|
||||||
|
"image": "kalender.jpg",
|
||||||
|
"crop": [
|
||||||
|
300,
|
||||||
|
35,
|
||||||
|
1240,
|
||||||
|
920
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
830,
|
||||||
|
660,
|
||||||
|
415,
|
||||||
|
155
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Im Monatskalender stehen Ausflüge, Elternabende, Feste und Schließtage.",
|
||||||
|
"Alle sehen die Termine im selben Zusammenhang."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 5,
|
||||||
|
"chapter": 2,
|
||||||
|
"title": [
|
||||||
|
"Zusagen pro",
|
||||||
|
"Familie."
|
||||||
|
],
|
||||||
|
"hint": "Anzahl der Erwachsenen angeben",
|
||||||
|
"image": "termin-details.jpg",
|
||||||
|
"crop": [
|
||||||
|
543,
|
||||||
|
335,
|
||||||
|
501,
|
||||||
|
370
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
561,
|
||||||
|
444,
|
||||||
|
470,
|
||||||
|
150
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Für dieses Herbstfest antwortet ihr pro Familie",
|
||||||
|
"und gebt an, wie viele Erwachsene teilnehmen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 6,
|
||||||
|
"chapter": 2,
|
||||||
|
"title": [
|
||||||
|
"Wer bringt",
|
||||||
|
"was mit?"
|
||||||
|
],
|
||||||
|
"hint": "Eigene Beiträge bearbeiten oder löschen",
|
||||||
|
"image": "termin-details.jpg",
|
||||||
|
"crop": [
|
||||||
|
543,
|
||||||
|
690,
|
||||||
|
501,
|
||||||
|
318
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
575,
|
||||||
|
748,
|
||||||
|
440,
|
||||||
|
62
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"In der Mitbringliste seht ihr, wer schon etwas beisteuert.",
|
||||||
|
"Eigene Beiträge könnt ihr bearbeiten oder löschen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 3,
|
||||||
|
"title": [
|
||||||
|
"Familien und",
|
||||||
|
"Kontakte finden."
|
||||||
|
],
|
||||||
|
"hint": "Haushalte und Kinder zusammen sehen",
|
||||||
|
"image": "adressbuch.jpg",
|
||||||
|
"crop": [
|
||||||
|
295,
|
||||||
|
35,
|
||||||
|
1220,
|
||||||
|
700
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
315,
|
||||||
|
325,
|
||||||
|
1080,
|
||||||
|
120
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Das interne Adressbuch verbindet Haushalte, Kinder",
|
||||||
|
"und die freigegebenen Kontakte der Eltern."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 3,
|
||||||
|
"title": [
|
||||||
|
"Freigabe bleibt",
|
||||||
|
"eine Entscheidung."
|
||||||
|
],
|
||||||
|
"hint": "Kontaktdaten erscheinen nach Opt-in",
|
||||||
|
"image": "adressbuch.jpg",
|
||||||
|
"crop": [
|
||||||
|
580,
|
||||||
|
290,
|
||||||
|
900,
|
||||||
|
310
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
638,
|
||||||
|
330,
|
||||||
|
420,
|
||||||
|
95
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Jeder Elternteil entscheidet selbst über die Freigabe seiner Kontaktdaten.",
|
||||||
|
"Im Adressbuch erscheinen nur ausdrücklich freigegebene Angaben."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 4,
|
||||||
|
"title": [
|
||||||
|
"Kind abmelden.",
|
||||||
|
"Kita informieren."
|
||||||
|
],
|
||||||
|
"hint": "Krankheit, Urlaub und andere Gründe",
|
||||||
|
"image": "abwesenheiten.jpg",
|
||||||
|
"crop": [
|
||||||
|
395,
|
||||||
|
25,
|
||||||
|
1060,
|
||||||
|
640
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
420,
|
||||||
|
320,
|
||||||
|
1010,
|
||||||
|
130
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Eltern melden Abwesenheiten digital.",
|
||||||
|
"Die Kita sieht die Meldung in ihrer Übersicht."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 4,
|
||||||
|
"title": [
|
||||||
|
"Pläne dürfen",
|
||||||
|
"sich ändern."
|
||||||
|
],
|
||||||
|
"hint": "Zukünftige Meldungen korrigieren",
|
||||||
|
"image": "abwesenheiten.jpg",
|
||||||
|
"crop": [
|
||||||
|
415,
|
||||||
|
305,
|
||||||
|
1020,
|
||||||
|
340
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
446,
|
||||||
|
565,
|
||||||
|
320,
|
||||||
|
45
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Zukünftige Abwesenheiten können Eltern bearbeiten oder zurücknehmen.",
|
||||||
|
"Sobald eine Meldung begonnen hat, übernimmt die Kita die Änderung."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 5,
|
||||||
|
"title": [
|
||||||
|
"Offizielle Infos",
|
||||||
|
"bleiben auffindbar."
|
||||||
|
],
|
||||||
|
"hint": "Das digitale Schwarze Brett",
|
||||||
|
"image": "mitteilungen.jpg",
|
||||||
|
"crop": [
|
||||||
|
410,
|
||||||
|
275,
|
||||||
|
780,
|
||||||
|
450
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
484,
|
||||||
|
367,
|
||||||
|
630,
|
||||||
|
80
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Mitteilungen bündeln offizielle Informationen an einem Ort.",
|
||||||
|
"Formatierter Text und der Aktualisierungshinweis machen Änderungen sichtbar."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 7,
|
||||||
|
"chapter": 5,
|
||||||
|
"title": [
|
||||||
|
"Auch nach einer",
|
||||||
|
"Korrektur klar."
|
||||||
|
],
|
||||||
|
"hint": "Geschützte Anhänge und optionale E-Mails",
|
||||||
|
"image": "mitteilungen.jpg",
|
||||||
|
"crop": [
|
||||||
|
450,
|
||||||
|
330,
|
||||||
|
700,
|
||||||
|
335
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
485,
|
||||||
|
580,
|
||||||
|
625,
|
||||||
|
53
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Kita-Admins können Beiträge korrigieren; Anhänge bleiben geschützt abrufbar.",
|
||||||
|
"Beim optionalen E-Mail-Versand bleibt der Status je Empfänger nachvollziehbar."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 8,
|
||||||
|
"chapter": 6,
|
||||||
|
"title": [
|
||||||
|
"Arbeitszeit für",
|
||||||
|
"das Kita-Team."
|
||||||
|
],
|
||||||
|
"hint": "Stunden erfassen und Wochen einreichen",
|
||||||
|
"image": "personal.jpg",
|
||||||
|
"crop": [
|
||||||
|
315,
|
||||||
|
445,
|
||||||
|
1210,
|
||||||
|
780
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
345,
|
||||||
|
1105,
|
||||||
|
780,
|
||||||
|
105
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Fachkräfte erfassen ihre Arbeitszeit mit Pausen",
|
||||||
|
"und reichen vollständige Wochen zur Prüfung ein."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 8,
|
||||||
|
"chapter": 6,
|
||||||
|
"title": [
|
||||||
|
"Zeitkonto und",
|
||||||
|
"Urlaub im Blick."
|
||||||
|
],
|
||||||
|
"hint": "Bestätigte Zeit von vorläufiger unterscheiden",
|
||||||
|
"image": "personal.jpg",
|
||||||
|
"crop": [
|
||||||
|
308,
|
||||||
|
235,
|
||||||
|
1210,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"focus": [
|
||||||
|
315,
|
||||||
|
275,
|
||||||
|
1185,
|
||||||
|
135
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Das bestätigte Zeitkonto und der verfügbare Urlaub bleiben sichtbar.",
|
||||||
|
"Urlaub und Freizeitausgleich lassen sich im Personalbereich beantragen."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duration": 10,
|
||||||
|
"chapter": 7,
|
||||||
|
"title": [
|
||||||
|
"Gemeinsam",
|
||||||
|
"besser planen."
|
||||||
|
],
|
||||||
|
"hint": "Startet mit den Abläufen, die euch entlasten",
|
||||||
|
"image": "kalender.jpg",
|
||||||
|
"crop": [
|
||||||
|
300,
|
||||||
|
35,
|
||||||
|
1240,
|
||||||
|
920
|
||||||
|
],
|
||||||
|
"subtitle": [
|
||||||
|
"Lernt Kita-Planer in Ruhe kennen und entdeckt die passenden Funktionen.",
|
||||||
|
"Alle gezeigten App-Ansichten verwenden ausschließlich Beispieldaten."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/** Synthetic fixture for public product screenshots. Never run against real data. */
|
||||||
|
import { PrismaClient, UserRole, TerminType, type User, type Child } from "@prisma/client";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
|
||||||
|
const url = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||||
|
if (!['localhost', '127.0.0.1'].includes(url.hostname) ||
|
||||||
|
url.pathname !== '/kita_screenshots_test') {
|
||||||
|
throw new Error('Screenshot fixtures require the isolated local kita_screenshots_test database.');
|
||||||
|
}
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const date = (value: string) => new Date(`${value}T00:00:00Z`);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (await prisma.kita.count() || await prisma.user.count()) {
|
||||||
|
throw new Error('Database must be empty; existing data will never be reset.');
|
||||||
|
}
|
||||||
|
const passwordHash = await hash('demo-screenshots-only', 10);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const kita = await tx.kita.create({ data: { name: 'Kita Sonnenwinkel', slug: 'sonnenwinkel-demo' } });
|
||||||
|
const kitaId = kita.id;
|
||||||
|
const common = { kitaId, passwordHash, emailVerifiedAt: date('2026-09-01'),
|
||||||
|
privacyPolicyAcceptedAt: date('2026-09-01'), privacyPolicyVersion: '2026-05-01' };
|
||||||
|
const admin = await tx.user.create({ data: { ...common, email: 'admin@example.invalid', firstName: 'Mara', lastName: 'Winter', role: UserRole.ADMIN } });
|
||||||
|
const households = [
|
||||||
|
['Berger', 'Lena', 'Jonas', 'Emma', 'Leo'],
|
||||||
|
['Hoffmann', 'Anna', 'Tim', 'Mila'],
|
||||||
|
['Schneider', 'Clara', 'Ben', 'Paul'],
|
||||||
|
['Neumann', 'Nora', 'Felix', 'Lotta'],
|
||||||
|
['Weber', 'Sarah', 'David', 'Finn'],
|
||||||
|
['Koch', 'Julia', 'Max', 'Ella'],
|
||||||
|
];
|
||||||
|
const parents: User[] = [];
|
||||||
|
const children: Child[] = [];
|
||||||
|
for (const [lastName, firstName, partner, ...childNames] of households) {
|
||||||
|
const family = await tx.family.create({ data: { kitaId, name: `Familie ${lastName}` } });
|
||||||
|
for (const name of [firstName, partner]) {
|
||||||
|
parents.push(await tx.user.create({ data: { ...common, familyId: family.id, firstName: name, lastName,
|
||||||
|
email: `${name.toLowerCase()}@example.invalid`, role: UserRole.ELTERN,
|
||||||
|
directoryOptInAt: date('2026-09-01'), city: 'Musterstadt' } }));
|
||||||
|
}
|
||||||
|
for (const name of childNames) {
|
||||||
|
children.push(await tx.child.create({ data: { kitaId, familyId: family.id, firstName: name, lastName } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.notdienstAvailability.createMany({ data: [2, 4, 6, 10, 12, 17, 20, 25].map(day => ({
|
||||||
|
kitaId, childId: children[0].id, userId: parents[0].id,
|
||||||
|
date: date(`2026-11-${String(day).padStart(2, '0')}`),
|
||||||
|
})) });
|
||||||
|
await tx.absence.createMany({ data: [
|
||||||
|
{ kitaId, childId: children[0].id, startDate: date('2026-10-12'), endDate: date('2026-10-14'), reason: 'ILLNESS' },
|
||||||
|
{ kitaId, childId: children[1].id, startDate: date('2026-10-26'), endDate: date('2026-10-30'), reason: 'VACATION', note: 'Familienurlaub in den Herbstferien' },
|
||||||
|
] });
|
||||||
|
const events: [string, TerminType, string, string, boolean][] = [
|
||||||
|
['Elterncafé', 'ELTERNCAFE', '2026-10-08T13:00:00Z', '2026-10-08T14:30:00Z', false],
|
||||||
|
['Ausflug in den Stadtwald', 'SONSTIGES', '2026-10-15T07:00:00Z', '2026-10-15T11:00:00Z', false],
|
||||||
|
['Elternabend', 'ELTERNABEND', '2026-10-20T16:00:00Z', '2026-10-20T18:00:00Z', false],
|
||||||
|
['Herbstfest im Garten', 'KITA_FEST', '2026-10-23T13:00:00Z', '2026-10-23T16:00:00Z', false],
|
||||||
|
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-10-30T00:00:00Z', '2026-10-30T23:59:00Z', true],
|
||||||
|
['Elterncafé', 'ELTERNCAFE', '2026-11-05T14:00:00Z', '2026-11-05T15:30:00Z', false],
|
||||||
|
['Laternenfest', 'KITA_FEST', '2026-11-11T15:00:00Z', '2026-11-11T17:00:00Z', false],
|
||||||
|
['Gartenaktion', 'MITMACH_TAG', '2026-11-14T09:00:00Z', '2026-11-14T12:00:00Z', false],
|
||||||
|
['Teamtag · Kita geschlossen', 'TEAMTAG', '2026-11-23T00:00:00Z', '2026-11-23T23:59:00Z', true],
|
||||||
|
];
|
||||||
|
for (const [title, type, start, end, allDay] of events) {
|
||||||
|
const event = await tx.termin.create({ data: { kitaId, title, type, status: 'CONFIRMED', startDate: new Date(start), endDate: new Date(end), allDay,
|
||||||
|
description: title.includes('Herbstfest') ? 'Wir feiern gemeinsam mit allen Familien. Für Snacks und Getränke gibt es eine Mitbringliste.' : 'Gemeinsam planen wir unseren Kita-Alltag.',
|
||||||
|
createdById: admin.id, approvedById: admin.id, approvedAt: date('2026-10-01'),
|
||||||
|
requiresRsvp: type === 'KITA_FEST', rsvpMode: 'FAMILY', mitbringselListEnabled: type === 'KITA_FEST' } });
|
||||||
|
if (type === 'KITA_FEST') {
|
||||||
|
await tx.familyEventParticipation.create({ data: { kitaId, eventId: event.id, familyId: parents[0].familyId!, status: 'ATTENDING', adultCount: 2 } });
|
||||||
|
await tx.mitbringselItem.createMany({ data: parents.slice(0, 3).map((parent, index) => ({ kitaId, terminId: event.id, userId: parent.id, content: ['Apfelkuchen', 'Gemüsesticks', 'Eine Kanne Tee'][index] })) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [title, content, created, updated] of [
|
||||||
|
['Herbstfest: Wir freuen uns auf euch!', 'Am **Freitag, 23. Oktober**, feiern wir ab 15 Uhr im Kita-Garten.\n\nBitte tragt euch im Kalender für die Teilnahme und in die Mitbringliste ein. Gemeinsam sorgen wir für ein buntes Buffet.\n\n**Aktualisierung:** Bei Regen feiern wir im Gruppenraum.', '2026-10-09', '2026-10-13'],
|
||||||
|
['Elternabend am 20. Oktober', 'Wir treffen uns um **18 Uhr** im großen Gruppenraum.\n\nAuf der Agenda stehen der Kita-Alltag, die nächsten Ausflüge und eure Fragen. Wir freuen uns auf den Austausch!', '2026-10-08', '2026-10-08'],
|
||||||
|
['Gemeinsam unseren Garten vorbereiten', 'Am 14. November machen wir unseren Garten winterfest. Kleine und große Helfer sind willkommen!\n\nHandschuhe und gute Laune reichen. Alle Details findet ihr im Kalender.', '2026-10-06', '2026-10-06'],
|
||||||
|
]) {
|
||||||
|
await tx.announcement.create({ data: { kitaId, authorId: admin.id, title, content, audience: 'ALLE',
|
||||||
|
createdAt: date(created), updatedAt: date(updated), revision: created === updated ? 1 : 2, editedAt: created === updated ? null : date(updated) } });
|
||||||
|
}
|
||||||
|
const staff = await tx.user.create({ data: { ...common, email: 'team@example.invalid', firstName: 'Sophie', lastName: 'Lind', role: UserRole.ERZIEHER } });
|
||||||
|
const educator = await tx.educator.create({ data: { kitaId, firstName: staff.firstName, lastName: staff.lastName } });
|
||||||
|
const profile = await tx.staffProfile.create({ data: { kitaId, educatorId: educator.id, userId: staff.id,
|
||||||
|
employmentStart: date('2026-01-01'), trackingStart: date('2026-10-01'), openingMinutes: 120,
|
||||||
|
workPatterns: { create: { effectiveFrom: date('2026-01-01'), dailyMinutes: [420,420,420,420,420,0,0] } },
|
||||||
|
leaveAccounts: { create: { year: 2026, entitlementUnits: 60, carryUnits: 0 } } } });
|
||||||
|
await tx.staffTimeEntry.createMany({ data: [5,6,7,8,9].map(day => ({ profileId: profile.id, date: date(`2026-10-${String(day).padStart(2,'0')}`), startMinute: 480, endMinute: 930, breakMinutes: 30 })) });
|
||||||
|
}, { timeout: 30000 });
|
||||||
|
console.log('Synthetic screenshot data ready. Logins: lena@example.invalid / team@example.invalid. Password: demo-screenshots-only.');
|
||||||
|
}
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
@@ -7,7 +7,8 @@ if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
unset POSTGRES_PASSWORD PGADMIN_DEFAULT_PASSWORD APP_DATABASE_PASSWORD MIGRATION_DATABASE_URL
|
unset POSTGRES_PASSWORD PGADMIN_DEFAULT_PASSWORD APP_DATABASE_PASSWORD MIGRATION_DATABASE_URL
|
||||||
# Schema changes are applied by the maintenance image before runtime startup.
|
# Schema changes are applied by the maintenance image before runtime startup.
|
||||||
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
|
if [ -n "${NOTDIENST_JOB_SECRET:-}" ] && [ "${#NOTDIENST_JOB_SECRET}" -lt 32 ]; then
|
||||||
node scripts/notdienst-alarm-worker.mjs &
|
echo "NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
exec node server.js
|
exec node scripts/start-runtime.mjs
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
let stopping = false,
|
||||||
|
worker,
|
||||||
|
restartTimer,
|
||||||
|
stopTimer;
|
||||||
|
const server = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
|
||||||
|
function shutdown(code = 0) {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
process.exitCode = code;
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
server.kill("SIGTERM");
|
||||||
|
worker?.kill("SIGTERM");
|
||||||
|
stopTimer = setTimeout(() => {
|
||||||
|
server.kill("SIGKILL");
|
||||||
|
worker?.kill("SIGKILL");
|
||||||
|
process.exit(code);
|
||||||
|
}, 4000);
|
||||||
|
stopTimer.unref();
|
||||||
|
Promise.all(
|
||||||
|
[server, worker]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((child) =>
|
||||||
|
child.exitCode !== null || child.signalCode !== null
|
||||||
|
? Promise.resolve()
|
||||||
|
: new Promise((resolve) => child.once("close", resolve)),
|
||||||
|
),
|
||||||
|
).then(() => {
|
||||||
|
clearTimeout(stopTimer);
|
||||||
|
process.exit(code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startWorker() {
|
||||||
|
if (stopping) return;
|
||||||
|
worker = spawn(process.execPath, ["scripts/notdienst-alarm-worker.mjs"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
worker.on("close", () => {
|
||||||
|
if (stopping) return;
|
||||||
|
console.error(
|
||||||
|
"Notdienst-Hintergrundjob beendet; Neustart in fünf Sekunden.",
|
||||||
|
);
|
||||||
|
restartTimer = setTimeout(startWorker, 5000);
|
||||||
|
});
|
||||||
|
worker.on("error", () => {
|
||||||
|
if (!stopping)
|
||||||
|
console.error("Notdienst-Hintergrundjob konnte nicht starten.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
server.on("exit", (code) => shutdown(code ?? 1));
|
||||||
|
server.on("error", () => shutdown(1));
|
||||||
|
for (const signal of ["SIGTERM", "SIGINT"])
|
||||||
|
process.on(signal, () => shutdown(0));
|
||||||
|
if ((process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32) startWorker();
|
||||||
|
else
|
||||||
|
console.warn(
|
||||||
|
"Automatische Notdienst-Jobs deaktiviert: gültiges NOTDIENST_JOB_SECRET fehlt.",
|
||||||
|
);
|
||||||
@@ -1,24 +1,33 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { AbsenceReason, UserRole } from "@prisma/client";
|
import { AbsenceReason, Prisma, UserRole } from "@prisma/client";
|
||||||
import { dateOnlySchema } from "@/lib/date-schema";
|
import { dateOnlySchema } from "@/lib/date-schema";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
absenceDate,
|
||||||
|
absenceToday,
|
||||||
|
canManageKitaAbsences,
|
||||||
|
} from "@/lib/absence-policy";
|
||||||
|
|
||||||
const reportAbsenceSchema = z.object({
|
const dateSchema = dateOnlySchema;
|
||||||
childId: z.string().min(1),
|
const fieldsSchema = z.object({
|
||||||
startDate: dateOnlySchema,
|
startDate: dateSchema,
|
||||||
endDate: dateOnlySchema,
|
endDate: dateSchema,
|
||||||
reason: z.nativeEnum(AbsenceReason),
|
reason: z.nativeEnum(AbsenceReason),
|
||||||
note: z.string().trim().max(500).optional(),
|
note: z.string().trim().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
const reportSchema = fieldsSchema.extend({
|
||||||
const dailyAbsenceSchema = z.object({
|
childId: z.string().min(1).max(128),
|
||||||
date: dateOnlySchema,
|
|
||||||
});
|
});
|
||||||
|
const identitySchema = z.object({
|
||||||
|
id: z.string().min(1).max(128),
|
||||||
|
updatedAt: z.string().datetime({ offset: true }),
|
||||||
|
});
|
||||||
|
const updateSchema = fieldsSchema.extend(identitySchema.shape);
|
||||||
|
type MutationResult = { success?: boolean; error?: string };
|
||||||
|
|
||||||
export type DailyAbsenceDto = {
|
export type DailyAbsenceDto = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,112 +37,216 @@ export type DailyAbsenceDto = {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseDateOnly(value: string) {
|
function refreshAbsences() {
|
||||||
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
|
revalidatePath("/dashboard");
|
||||||
if (Number.isNaN(date.getTime())) {
|
revalidatePath("/dashboard/abwesenheiten");
|
||||||
throw new Error("Ungueltiges Datum.");
|
revalidatePath("/dashboard/admin/abwesenheiten");
|
||||||
}
|
|
||||||
return date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateKey(date: Date) {
|
async function currentActor(
|
||||||
return date.toISOString().slice(0, 10);
|
tx: Prisma.TransactionClient,
|
||||||
}
|
id: string,
|
||||||
|
kitaId: string,
|
||||||
export async function reportAbsence(rawPayload: unknown) {
|
) {
|
||||||
const session = await requireKitaSession();
|
return tx.user.findFirst({
|
||||||
const parsed = reportAbsenceSchema.safeParse(rawPayload);
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { error: "Ungültige Eingabedaten." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.user.familyId) {
|
|
||||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = parseDateOnly(parsed.data.startDate);
|
|
||||||
const endDate = parseDateOnly(parsed.data.endDate);
|
|
||||||
|
|
||||||
if (endDate < startDate) {
|
|
||||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = await prisma.child.findFirst({
|
|
||||||
where: {
|
where: {
|
||||||
id: parsed.data.childId,
|
id,
|
||||||
|
kitaId,
|
||||||
|
role: {
|
||||||
|
in: [
|
||||||
|
UserRole.ELTERN,
|
||||||
|
UserRole.ADMIN,
|
||||||
|
UserRole.KOORDINATOR,
|
||||||
|
UserRole.ERZIEHER,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, role: true, familyId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockChild(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
childId: string,
|
||||||
|
kitaId: string,
|
||||||
|
) {
|
||||||
|
// Serializes overlapping create/edit/delete operations for this child.
|
||||||
|
await tx.$queryRaw`SELECT "id" FROM "children" WHERE "id" = ${childId} AND "kitaId" = ${kitaId} FOR UPDATE`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportAbsence(
|
||||||
|
rawPayload: unknown,
|
||||||
|
): Promise<MutationResult> {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = reportSchema.safeParse(rawPayload);
|
||||||
|
if (!parsed.success)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Bitte gültige Daten und einen Grund angeben. Die Bemerkung darf höchstens 500 Zeichen haben.",
|
||||||
|
};
|
||||||
|
const { childId, startDate, endDate, reason, note } = parsed.data;
|
||||||
|
if (endDate < startDate)
|
||||||
|
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
|
||||||
|
if (!actor?.familyId)
|
||||||
|
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
|
||||||
|
if (!canManageKitaAbsences(actor.role) && startDate < absenceToday())
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Vergangene Tage kann nur die Kita nachtragen. Bitte melde dich dort.",
|
||||||
|
};
|
||||||
|
await lockChild(tx, childId, session.user.kitaId);
|
||||||
|
const child = await tx.child.findFirst({
|
||||||
|
where: {
|
||||||
|
id: childId,
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
familyId: session.user.familyId,
|
familyId: actor.familyId,
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (!child)
|
||||||
if (!child) {
|
return {
|
||||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
error:
|
||||||
}
|
"Dieses Kind gehört nicht zu deinem Haushalt oder ist nicht mehr aktiv.",
|
||||||
|
};
|
||||||
const overlappingAbsence = await prisma.absence.findFirst({
|
const start = absenceDate(startDate),
|
||||||
|
end = absenceDate(endDate);
|
||||||
|
const overlap = await tx.absence.findFirst({
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
childId: child.id,
|
childId,
|
||||||
|
startDate: { lte: end },
|
||||||
|
endDate: { gte: start },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (overlap)
|
||||||
|
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
|
||||||
|
await tx.absence.create({
|
||||||
|
data: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
childId,
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
reason,
|
||||||
|
note: note || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
if (result.success) refreshAbsences();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeAbsence(
|
||||||
|
rawPayload: unknown,
|
||||||
|
remove: boolean,
|
||||||
|
): Promise<MutationResult> {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = remove
|
||||||
|
? identitySchema.safeParse(rawPayload)
|
||||||
|
: updateSchema.safeParse(rawPayload);
|
||||||
|
if (!parsed.success)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Ungültige Eingabedaten. Bitte die Ansicht aktualisieren und gültige Daten angeben.",
|
||||||
|
};
|
||||||
|
const input = parsed.data;
|
||||||
|
const fields = remove ? null : updateSchema.parse(rawPayload);
|
||||||
|
if (fields && fields.endDate < fields.startDate)
|
||||||
|
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const actor = await currentActor(tx, session.user.id, session.user.kitaId);
|
||||||
|
if (!actor) return { error: "Keine Berechtigung für diese Meldung." };
|
||||||
|
const team = canManageKitaAbsences(actor.role);
|
||||||
|
if (!team && !actor.familyId)
|
||||||
|
return { error: "Dein Account ist keinem Haushalt zugeordnet." };
|
||||||
|
const where: Prisma.AbsenceWhereInput = {
|
||||||
|
id: input.id,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
child: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
...(team ? {} : { familyId: actor.familyId! }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const candidate = await tx.absence.findFirst({
|
||||||
|
where,
|
||||||
|
select: { childId: true },
|
||||||
|
});
|
||||||
|
if (!candidate)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Meldung wurde nicht gefunden oder gehört nicht zu deinem Haushalt.",
|
||||||
|
};
|
||||||
|
await lockChild(tx, candidate.childId, session.user.kitaId);
|
||||||
|
const absence = await tx.absence.findFirst({ where });
|
||||||
|
if (!absence)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Die Meldung ist nicht mehr vorhanden. Bitte die Ansicht aktualisieren.",
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
absence.updatedAt.toISOString() !==
|
||||||
|
new Date(input.updatedAt).toISOString()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Die Meldung wurde inzwischen geändert. Bitte die Ansicht aktualisieren.",
|
||||||
|
};
|
||||||
|
const today = absenceToday();
|
||||||
|
if (
|
||||||
|
!team &&
|
||||||
|
(absence.startDate.toISOString().slice(0, 10) <= today ||
|
||||||
|
(fields && fields.startDate <= today))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Bereits begonnene Meldungen und laufende Tage verwaltet die Kita. Bitte melde dich dort.",
|
||||||
|
};
|
||||||
|
if (remove) await tx.absence.delete({ where: { id: absence.id } });
|
||||||
|
else if (fields) {
|
||||||
|
const startDate = absenceDate(fields.startDate),
|
||||||
|
endDate = absenceDate(fields.endDate);
|
||||||
|
const overlap = await tx.absence.findFirst({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
childId: absence.childId,
|
||||||
|
id: { not: absence.id },
|
||||||
startDate: { lte: endDate },
|
startDate: { lte: endDate },
|
||||||
endDate: { gte: startDate },
|
endDate: { gte: startDate },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (overlap)
|
||||||
if (overlappingAbsence) {
|
return {
|
||||||
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
|
error: "Für diesen Zeitraum existiert bereits eine andere Abmeldung.",
|
||||||
}
|
};
|
||||||
|
await tx.absence.update({
|
||||||
await prisma.absence.create({
|
where: { id: absence.id },
|
||||||
data: {
|
data: {
|
||||||
kitaId: session.user.kitaId,
|
|
||||||
childId: child.id,
|
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
reason: parsed.data.reason,
|
reason: fields.reason,
|
||||||
note: parsed.data.note || null,
|
note: fields.note || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
revalidatePath("/dashboard");
|
|
||||||
revalidatePath("/dashboard/admin/abwesenheiten");
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAbsence(id: string) {
|
|
||||||
const session = await requireKitaSession();
|
|
||||||
|
|
||||||
if (!session.user.familyId) {
|
|
||||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const absence = await prisma.absence.findFirst({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
kitaId: session.user.kitaId,
|
|
||||||
child: {
|
|
||||||
familyId: session.user.familyId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
if (result.success) refreshAbsences();
|
||||||
if (!absence) {
|
return result;
|
||||||
return { error: "Diese Abmeldung gehört nicht zu deinem Haushalt." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.absence.delete({
|
export async function updateAbsence(rawPayload: unknown) {
|
||||||
where: { id: absence.id },
|
return changeAbsence(rawPayload, false);
|
||||||
});
|
}
|
||||||
|
export async function deleteAbsence(rawPayload: unknown) {
|
||||||
revalidatePath("/dashboard");
|
return changeAbsence(rawPayload, true);
|
||||||
revalidatePath("/dashboard/admin/abwesenheiten");
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDailyAbsences(
|
export async function getDailyAbsences(
|
||||||
@@ -144,17 +257,10 @@ export async function getDailyAbsences(
|
|||||||
UserRole.KOORDINATOR,
|
UserRole.KOORDINATOR,
|
||||||
UserRole.ERZIEHER,
|
UserRole.ERZIEHER,
|
||||||
]);
|
]);
|
||||||
|
if (!session.user.kitaId) return { error: "Kein Mandant zugeordnet." };
|
||||||
if (!session.user.kitaId) {
|
const parsed = z.object({ date: dateSchema }).safeParse(rawPayload);
|
||||||
return { error: "Kein Mandant zugeordnet." };
|
if (!parsed.success) return { error: "Ungültiges Datum." };
|
||||||
}
|
const date = absenceDate(parsed.data.date);
|
||||||
|
|
||||||
const parsed = dailyAbsenceSchema.safeParse(rawPayload);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { error: "Ungültiges Datum." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = parseDateOnly(parsed.data.date);
|
|
||||||
const rows = await prisma.absence.findMany({
|
const rows = await prisma.absence.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
@@ -167,24 +273,17 @@ export async function getDailyAbsences(
|
|||||||
note: true,
|
note: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
|
updatedAt: true,
|
||||||
child: {
|
child: {
|
||||||
select: {
|
select: {
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
family: {
|
family: { select: { name: true } },
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
orderBy: [{ child: { lastName: "asc" } }, { child: { firstName: "asc" } }],
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{ child: { lastName: "asc" } },
|
|
||||||
{ child: { firstName: "asc" } },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
absences: rows.map((absence) => ({
|
absences: rows.map((absence) => ({
|
||||||
id: absence.id,
|
id: absence.id,
|
||||||
@@ -192,8 +291,9 @@ export async function getDailyAbsences(
|
|||||||
familyName: absence.child.family.name,
|
familyName: absence.child.family.name,
|
||||||
reason: absence.reason,
|
reason: absence.reason,
|
||||||
note: absence.note,
|
note: absence.note,
|
||||||
startDate: toDateKey(absence.startDate),
|
startDate: absence.startDate.toISOString().slice(0, 10),
|
||||||
endDate: toDateKey(absence.endDate),
|
endDate: absence.endDate.toISOString().slice(0, 10),
|
||||||
|
updatedAt: absence.updatedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,50 @@
|
|||||||
|
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { mkdir, rm, writeFile } from "fs/promises";
|
import { mkdir, rm, writeFile } from "fs/promises";
|
||||||
import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches } from "@/lib/attachments";
|
import {
|
||||||
|
attachmentDirectory,
|
||||||
|
attachmentPath,
|
||||||
|
MAX_ATTACHMENT_SIZE,
|
||||||
|
detectAttachmentType,
|
||||||
|
attachmentExtensionMatches,
|
||||||
|
} from "@/lib/attachments";
|
||||||
import { serializableTransaction } from "@/lib/transaction";
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { createElement } from "react";
|
import { after } from "next/server";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client";
|
import { UserRole } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { NewsEmail } from "@/emails/NewsEmail";
|
|
||||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
announcementRecipientWhere,
|
assertAnnouncementAdmin,
|
||||||
visibleAnnouncementAudiences,
|
assertAnnouncementEditable,
|
||||||
} from "@/lib/announcement-access";
|
deliverDueAnnouncementEmails,
|
||||||
|
lockAnnouncement,
|
||||||
|
queueAnnouncementEmails,
|
||||||
|
} from "@/lib/announcement-delivery";
|
||||||
|
import { publicActionError, UserFacingError } from "@/lib/action-error";
|
||||||
|
|
||||||
|
function revalidateAnnouncements() {
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/dashboard/admin/news");
|
||||||
|
revalidatePath("/dashboard/mitteilungen");
|
||||||
|
}
|
||||||
|
function startAnnouncementDelivery(kitaId: string, announcementId: string) {
|
||||||
|
after(async () => {
|
||||||
|
try {
|
||||||
|
await deliverDueAnnouncementEmails({ kitaId, announcementId });
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
"Mitteilungsversand wird im nächsten Hintergrundlauf erneut versucht.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
revalidateAnnouncements();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
|
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
|
||||||
|
|
||||||
@@ -32,17 +60,6 @@ function getSafeFileName(name: string) {
|
|||||||
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
|
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
|
||||||
const where: Prisma.UserWhereInput = { kitaId, emailVerifiedAt: { not: null }, ...announcementRecipientWhere(audience as AnnouncementAudience) };
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
|
||||||
select: { email: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(new Set(users.map((user) => user.email)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAnnouncement(formData: FormData) {
|
export async function createAnnouncement(formData: FormData) {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId;
|
||||||
@@ -66,47 +83,89 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
.getAll("attachments")
|
.getAll("attachments")
|
||||||
.filter((value): value is File => value instanceof File && value.size > 0);
|
.filter((value): value is File => value instanceof File && value.size > 0);
|
||||||
|
|
||||||
if (attachmentFiles.length > 5 || attachmentFiles.some(file => file.size > MAX_ATTACHMENT_SIZE) ||
|
if (
|
||||||
attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024) {
|
attachmentFiles.length > 5 ||
|
||||||
return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt." };
|
attachmentFiles.some((file) => file.size > MAX_ATTACHMENT_SIZE) ||
|
||||||
|
attachmentFiles.reduce((total, file) => total + file.size, 0) >
|
||||||
|
25 * 1024 * 1024
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600)).allowed) {
|
if (
|
||||||
return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut." };
|
!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600))
|
||||||
|
.allowed
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
error: "Zu viele Ankündigungen. Bitte versuche es später erneut.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const attachmentInputs: { id: string; bytes: Buffer; fileName: string; fileType: string; fileUrl: string }[] = [];
|
const attachmentInputs: {
|
||||||
|
id: string;
|
||||||
|
bytes: Buffer;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
fileUrl: string;
|
||||||
|
}[] = [];
|
||||||
for (const file of attachmentFiles) {
|
for (const file of attachmentFiles) {
|
||||||
const bytes = Buffer.from(await file.arrayBuffer());
|
const bytes = Buffer.from(await file.arrayBuffer());
|
||||||
const fileType = detectAttachmentType(bytes);
|
const fileType = detectAttachmentType(bytes);
|
||||||
if (!fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType)) {
|
if (
|
||||||
return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt." };
|
!fileType ||
|
||||||
|
!attachmentExtensionMatches(file.name, fileType) ||
|
||||||
|
(file.type && file.type !== fileType)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType,
|
attachmentInputs.push({
|
||||||
fileUrl: `/api/announcements/attachments/${id}` });
|
id,
|
||||||
|
bytes,
|
||||||
|
fileName: getSafeFileName(file.name),
|
||||||
|
fileType,
|
||||||
|
fileUrl: `/api/announcements/attachments/${id}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const announcement = await serializableTransaction(async tx => {
|
const announcement = await serializableTransaction(async (tx) => {
|
||||||
if (attachmentInputs.length) {
|
if (attachmentInputs.length) {
|
||||||
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
|
const pending = await tx.attachment.count({ where: { sizeBytes: null } });
|
||||||
if (pending) return null;
|
if (pending) return null;
|
||||||
const [tenantUsage, globalUsage] = await Promise.all([
|
const [tenantUsage, globalUsage] = await Promise.all([
|
||||||
tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }),
|
tx.attachment.aggregate({
|
||||||
|
where: { announcement: { kitaId } },
|
||||||
|
_sum: { sizeBytes: true },
|
||||||
|
}),
|
||||||
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
|
tx.attachment.aggregate({ _sum: { sizeBytes: true } }),
|
||||||
]);
|
]);
|
||||||
const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0);
|
const bytes = attachmentInputs.reduce(
|
||||||
|
(total, item) => total + item.bytes.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const quota = (value: string | undefined, fallback: number) => {
|
const quota = (value: string | undefined, fallback: number) => {
|
||||||
const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
};
|
};
|
||||||
if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
|
if (
|
||||||
(globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null;
|
(tenantUsage._sum.sizeBytes ?? 0) + bytes >
|
||||||
|
quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) ||
|
||||||
|
(globalUsage._sum.sizeBytes ?? 0) + bytes >
|
||||||
|
quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return tx.announcement.create({
|
const row = await tx.announcement.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId,
|
kitaId,
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
content: parsed.data.content,
|
content: parsed.data.content,
|
||||||
audience: parsed.data.audience,
|
audience: parsed.data.audience,
|
||||||
authorId: session.user.id,
|
authorId: session.user.id,
|
||||||
|
emailReady: attachmentInputs.length === 0,
|
||||||
attachments: {
|
attachments: {
|
||||||
create: attachmentInputs.map((attachment) => ({
|
create: attachmentInputs.map((attachment) => ({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
@@ -121,10 +180,19 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
|
kitaId: true,
|
||||||
|
audience: true,
|
||||||
|
revision: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (parsed.data.sendEmail) await queueAnnouncementEmails(tx, row);
|
||||||
|
return row;
|
||||||
});
|
});
|
||||||
if (!announcement) return { error: "Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration." };
|
if (!announcement)
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration.",
|
||||||
|
};
|
||||||
|
|
||||||
if (attachmentInputs.length > 0) {
|
if (attachmentInputs.length > 0) {
|
||||||
const uploadDir = attachmentDirectory();
|
const uploadDir = attachmentDirectory();
|
||||||
@@ -132,11 +200,10 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
await mkdir(uploadDir, { recursive: true });
|
await mkdir(uploadDir, { recursive: true });
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
attachmentInputs.map(async (attachment) => {
|
attachmentInputs.map(async (attachment) => {
|
||||||
await writeFile(
|
await writeFile(attachmentPath(attachment.id), attachment.bytes, {
|
||||||
attachmentPath(attachment.id),
|
flag: "wx",
|
||||||
attachment.bytes,
|
mode: 0o600,
|
||||||
{ flag: "wx", mode: 0o600 },
|
});
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -154,79 +221,211 @@ export async function createAnnouncement(formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let emailError: string | null = null;
|
if (attachmentInputs.length)
|
||||||
if (parsed.data.sendEmail) {
|
await prisma.announcement.update({
|
||||||
const configError = getAppEmailConfigError();
|
where: { id: announcement.id, kitaId },
|
||||||
if (configError) {
|
data: { emailReady: true },
|
||||||
emailError = configError;
|
});
|
||||||
} else {
|
if (parsed.data.sendEmail) startAnnouncementDelivery(kitaId, announcement.id);
|
||||||
const recipients = await getRecipients(kitaId, parsed.data.audience);
|
revalidateAnnouncements();
|
||||||
if (recipients.length > 0) {
|
return { success: true, warning: undefined };
|
||||||
const result = await sendAppEmail({
|
}
|
||||||
to: recipients,
|
|
||||||
subject: `Neue Kita-Ankündigung: ${announcement.title}`,
|
const editSchema = z.object({
|
||||||
react: createElement(NewsEmail, {
|
id: z.string().min(1).max(64),
|
||||||
title: announcement.title,
|
revision: z.coerce.number().int().positive(),
|
||||||
content: announcement.content,
|
title: announcementSchema.shape.title,
|
||||||
dashboardUrl: `${process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"}/dashboard`,
|
content: announcementSchema.shape.content,
|
||||||
}),
|
sendEmail: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
export async function updateAnnouncement(formData: FormData) {
|
||||||
emailError = result.error;
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
}
|
const input = editSchema.safeParse({
|
||||||
}
|
id: formData.get("id"),
|
||||||
}
|
revision: formData.get("revision"),
|
||||||
}
|
title: formData.get("title"),
|
||||||
|
content: formData.get("content"),
|
||||||
revalidatePath("/dashboard");
|
sendEmail: formData.get("sendEmail") === "on",
|
||||||
revalidatePath("/dashboard/admin/news");
|
});
|
||||||
revalidatePath("/dashboard/mitteilungen");
|
if (!input.success)
|
||||||
|
|
||||||
if (emailError) {
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
error:
|
||||||
warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`,
|
"Bitte Titel (3–160 Zeichen) und Inhalt (3–20.000 Zeichen) prüfen.",
|
||||||
|
};
|
||||||
|
const actor = {
|
||||||
|
id: session.user.id,
|
||||||
|
kitaId: session.user.kitaId!,
|
||||||
|
sessionVersion: session.user.sessionVersion,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
await rateLimit(`announcement-edit:${actor.kitaId}:${actor.id}`, 30, 3600)
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
|
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
|
||||||
|
try {
|
||||||
|
const result = await serializableTransaction(async (tx) => {
|
||||||
|
await assertAnnouncementAdmin(tx, actor);
|
||||||
|
const previous = await assertAnnouncementEditable(
|
||||||
|
tx,
|
||||||
|
actor.kitaId,
|
||||||
|
input.data.id,
|
||||||
|
input.data.revision,
|
||||||
|
);
|
||||||
|
const changed =
|
||||||
|
previous.title !== input.data.title ||
|
||||||
|
previous.content !== input.data.content;
|
||||||
|
const row = changed
|
||||||
|
? await tx.announcement.update({
|
||||||
|
where: { id: previous.id, kitaId: actor.kitaId },
|
||||||
|
data: {
|
||||||
|
title: input.data.title,
|
||||||
|
content: input.data.content,
|
||||||
|
editedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
revision: { increment: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: previous;
|
||||||
|
if (changed) {
|
||||||
|
await tx.announcementRead.deleteMany({
|
||||||
|
where: { kitaId: actor.kitaId, announcementId: row.id },
|
||||||
|
});
|
||||||
|
await tx.announcementEmail.updateMany({
|
||||||
|
where: {
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
announcementId: row.id,
|
||||||
|
sentAt: null,
|
||||||
|
skippedAt: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
skippedAt: new Date(),
|
||||||
|
failed: false,
|
||||||
|
lastError: "Durch eine neuere Beitragsversion ersetzt.",
|
||||||
|
claimToken: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const queued = input.data.sendEmail
|
||||||
|
? await queueAnnouncementEmails(tx, row)
|
||||||
|
: 0;
|
||||||
|
return { changed, queued };
|
||||||
|
});
|
||||||
|
if (result.queued) startAnnouncementDelivery(actor.kitaId, input.data.id);
|
||||||
|
revalidateAnnouncements();
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAnnouncementRead(announcementId: string) {
|
export async function retryAnnouncementEmails(id: string, revision: number) {
|
||||||
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
if (
|
||||||
|
!z.string().min(1).max(64).safeParse(id).success ||
|
||||||
|
!Number.isSafeInteger(revision) ||
|
||||||
|
revision < 1
|
||||||
|
)
|
||||||
|
return { error: "Ungültiger Beitrag." };
|
||||||
|
const actor = {
|
||||||
|
id: session.user.id,
|
||||||
|
kitaId: session.user.kitaId!,
|
||||||
|
sessionVersion: session.user.sessionVersion,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
await rateLimit(
|
||||||
|
`announcement-retry:${actor.kitaId}:${actor.id}`,
|
||||||
|
20,
|
||||||
|
3600,
|
||||||
|
)
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
error: "Zu viele Versandversuche. Bitte versuche es später erneut.",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const queued = await serializableTransaction(async (tx) => {
|
||||||
|
await assertAnnouncementAdmin(tx, actor);
|
||||||
|
await assertAnnouncementEditable(tx, actor.kitaId, id, revision);
|
||||||
|
return (
|
||||||
|
await tx.announcementEmail.updateMany({
|
||||||
|
where: {
|
||||||
|
kitaId: actor.kitaId,
|
||||||
|
announcementId: id,
|
||||||
|
revision,
|
||||||
|
sentAt: null,
|
||||||
|
skippedAt: null,
|
||||||
|
failed: true,
|
||||||
|
},
|
||||||
|
data: { nextAttemptAt: new Date(), failed: false },
|
||||||
|
})
|
||||||
|
).count;
|
||||||
|
});
|
||||||
|
if (queued) startAnnouncementDelivery(actor.kitaId, id);
|
||||||
|
revalidateAnnouncements();
|
||||||
|
return { success: true, queued };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Versand konnte nicht erneut gestartet werden.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAnnouncementRead(
|
||||||
|
announcementId: string,
|
||||||
|
revision = 1,
|
||||||
|
) {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
const announcement = await prisma.announcement.findFirst({
|
if (!Number.isSafeInteger(revision) || revision < 1)
|
||||||
|
return { error: "Ungültige Beitragsversion." };
|
||||||
|
try {
|
||||||
|
await serializableTransaction(async (tx) => {
|
||||||
|
await lockAnnouncement(tx, session.user.kitaId, announcementId);
|
||||||
|
const announcement = await tx.announcement.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: announcementId,
|
id: announcementId,
|
||||||
|
revision,
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
audience: { in: visibleAnnouncementAudiences(session.user) },
|
audience: { in: visibleAnnouncementAudiences(session.user) },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (!announcement)
|
||||||
if (!announcement) {
|
throw new UserFacingError(
|
||||||
return { error: "Ankündigung wurde nicht gefunden." };
|
"Der Beitrag wurde geändert oder ist nicht verfügbar. Bitte lade die Seite neu.",
|
||||||
}
|
);
|
||||||
|
await tx.announcementRead.upsert({
|
||||||
await prisma.announcementRead.upsert({
|
|
||||||
where: {
|
where: {
|
||||||
userId_announcementId: {
|
userId_announcementId: { userId: session.user.id, announcementId },
|
||||||
userId: session.user.id,
|
|
||||||
announcementId: announcement.id,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
announcementId: announcement.id,
|
announcementId,
|
||||||
},
|
|
||||||
update: {
|
|
||||||
readAt: new Date(),
|
|
||||||
},
|
},
|
||||||
|
update: { readAt: new Date() },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
revalidatePath("/dashboard");
|
} catch (error) {
|
||||||
revalidatePath("/dashboard/mitteilungen");
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Lesestatus konnte nicht gespeichert werden.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
revalidateAnnouncements();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
|
import {
|
||||||
|
assertKitaAdminRemains,
|
||||||
|
lockKitaAccounts,
|
||||||
|
} from "@/lib/kita-admin-guard";
|
||||||
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import { UserFacingError } from "@/lib/action-error";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
const deleteKitaSchema = z.object({
|
const deleteKitaSchema = z.object({
|
||||||
@@ -28,7 +34,8 @@ type ActionResult = {
|
|||||||
|
|
||||||
async function requireSuperadminActor() {
|
async function requireSuperadminActor() {
|
||||||
const session = await requireRole([UserRole.SUPERADMIN]);
|
const session = await requireRole([UserRole.SUPERADMIN]);
|
||||||
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
|
if (Date.now() - session.user.authenticatedAt > 15 * 60_000)
|
||||||
|
redirect("/login?reauth=1&callbackUrl=/admin");
|
||||||
return {
|
return {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
|
||||||
@@ -96,7 +103,10 @@ export async function deleteKita(
|
|||||||
const counts = await getKitaDeleteCounts(kita.id);
|
const counts = await getKitaDeleteCounts(kita.id);
|
||||||
|
|
||||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
const attachmentIds = await prisma.$transaction(async (tx) => {
|
||||||
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
|
const attachments = await tx.attachment.findMany({
|
||||||
|
where: { announcement: { kitaId: kita.id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
await tx.auditLog.create({
|
await tx.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
actorUserId: actor.id,
|
actorUserId: actor.id,
|
||||||
@@ -111,7 +121,7 @@ export async function deleteKita(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await tx.kita.delete({ where: { id: kita.id } });
|
await tx.kita.delete({ where: { id: kita.id } });
|
||||||
return attachments.map(item => item.id);
|
return attachments.map((item) => item.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
await removeAttachmentFiles(attachmentIds);
|
await removeAttachmentFiles(attachmentIds);
|
||||||
@@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
|
|||||||
return { error: "Benutzer wurde nicht gefunden." } as const;
|
return { error: "Benutzer wurde nicht gefunden." } as const;
|
||||||
}
|
}
|
||||||
if (user.id === actorId) {
|
if (user.id === actorId) {
|
||||||
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
|
return {
|
||||||
|
error: "Das eigene Superadmin-Konto kann hier nicht geändert werden.",
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
if (user.role === UserRole.SUPERADMIN) {
|
if (user.role === UserRole.SUPERADMIN) {
|
||||||
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
|
return {
|
||||||
|
error: "Andere Superadmins werden in V1 nicht verwaltet.",
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
if (!user.kitaId) {
|
if (!user.kitaId) {
|
||||||
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
|
return {
|
||||||
|
error: "Nur tenant-gebundene Benutzer können verwaltet werden.",
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user } as const;
|
return { user } as const;
|
||||||
@@ -188,8 +204,15 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentIds = await prisma.$transaction(async (tx) => {
|
let attachmentIds: string[];
|
||||||
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } });
|
try {
|
||||||
|
attachmentIds = await serializableTransaction(async (tx) => {
|
||||||
|
await lockKitaAccounts(tx, user.kitaId!);
|
||||||
|
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||||
|
const attachments = await tx.attachment.findMany({
|
||||||
|
where: { announcement: { authorId: user.id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
await tx.verificationToken.deleteMany({
|
await tx.verificationToken.deleteMany({
|
||||||
where: { identifier: user.id },
|
where: { identifier: user.id },
|
||||||
});
|
});
|
||||||
@@ -213,9 +236,12 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.user.delete({ where: { id: user.id } });
|
await tx.user.delete({ where: { id: user.id } });
|
||||||
return attachments.map(item => item.id);
|
return attachments.map((item) => item.id);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UserFacingError)) throw error;
|
||||||
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
await removeAttachmentFiles(attachmentIds);
|
await removeAttachmentFiles(attachmentIds);
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||||
@@ -245,10 +271,19 @@ async function updateUserSupportState(
|
|||||||
|
|
||||||
const { user } = lookup;
|
const { user } = lookup;
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
try {
|
||||||
|
await serializableTransaction(async (tx) => {
|
||||||
|
await lockKitaAccounts(tx, user.kitaId!);
|
||||||
|
if (action === "USER_LOCK")
|
||||||
|
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
|
data: {
|
||||||
|
...data,
|
||||||
|
...(action === "USER_LOCK"
|
||||||
|
? { sessionVersion: { increment: 1 } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await tx.auditLog.create({
|
await tx.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -267,7 +302,10 @@ async function updateUserSupportState(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UserFacingError)) throw error;
|
||||||
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
revalidatePath(`/admin/kitas/${user.kitaId}`);
|
||||||
revalidatePath("/admin/audit");
|
revalidatePath("/admin/audit");
|
||||||
@@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts(
|
|||||||
failedLoginAttempts: 0,
|
failedLoginAttempts: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
||||||
|
|
||||||
|
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
|
||||||
|
|
||||||
|
import { deliverDueAnnouncementEmails } from "@/lib/announcement-delivery";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -16,9 +20,26 @@ export async function POST(request: Request) {
|
|||||||
{ status },
|
{ status },
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = await runAlarmJob();
|
const [alarm, planning, announcements] = await Promise.allSettled([
|
||||||
|
runAlarmJob(),
|
||||||
|
runNotdienstNotificationJob(),
|
||||||
|
deliverDueAnnouncementEmails(),
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
alarm.status === "rejected" ||
|
||||||
|
planning.status === "rejected" ||
|
||||||
|
announcements.status === "rejected"
|
||||||
|
)
|
||||||
|
throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
|
||||||
|
const result = {
|
||||||
|
...alarm.value,
|
||||||
|
planning: planning.value,
|
||||||
|
announcements: announcements.value,
|
||||||
|
};
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
revalidatePath("/dashboard/notdienst/alarm");
|
revalidatePath("/dashboard/notdienst/alarm");
|
||||||
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
|
revalidatePath("/dashboard/admin/news");
|
||||||
return Response.json(result);
|
return Response.json(result);
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { AbsenceReason } from "@prisma/client";
|
import { AbsenceReason } from "@prisma/client";
|
||||||
import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
|
import { Baby, CalendarX2, Check, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { reportAbsence } from "@/actions/absences";
|
import { reportAbsence } from "@/actions/absences";
|
||||||
|
import { AbsenceManagementActions } from "@/components/absence-management-actions";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +41,7 @@ type AbsenceListItem = {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AbsenceCard({
|
export function AbsenceCard({
|
||||||
@@ -47,10 +49,11 @@ export function AbsenceCard({
|
|||||||
today,
|
today,
|
||||||
}: {
|
}: {
|
||||||
childOptions: ChildOption[];
|
childOptions: ChildOption[];
|
||||||
absences: AbsenceListItem[];
|
|
||||||
today: string;
|
today: string;
|
||||||
}) {
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [startDate, setStartDate] = useState(today);
|
||||||
|
const [endDate, setEndDate] = useState(today);
|
||||||
const defaultChildId = childOptions[0]?.id;
|
const defaultChildId = childOptions[0]?.id;
|
||||||
const hasChildren = childOptions.length > 0;
|
const hasChildren = childOptions.length > 0;
|
||||||
|
|
||||||
@@ -92,7 +95,17 @@ export function AbsenceCard({
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<form action={handleSubmit}>
|
<form
|
||||||
|
onReset={() => {
|
||||||
|
setStartDate(today);
|
||||||
|
setEndDate(today);
|
||||||
|
}}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSubmit(new FormData(event.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset disabled={isPending}>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FieldLabel label="Welches Kind?">
|
<FieldLabel label="Welches Kind?">
|
||||||
<Select name="childId" defaultValue={defaultChildId} required>
|
<Select name="childId" defaultValue={defaultChildId} required>
|
||||||
@@ -119,7 +132,9 @@ export function AbsenceCard({
|
|||||||
<SelectValue placeholder="Grund auswählen" />
|
<SelectValue placeholder="Grund auswählen" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={AbsenceReason.ILLNESS}>Krank</SelectItem>
|
<SelectItem value={AbsenceReason.ILLNESS}>
|
||||||
|
Krank
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value={AbsenceReason.VACATION}>
|
<SelectItem value={AbsenceReason.VACATION}>
|
||||||
Urlaub
|
Urlaub
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -135,7 +150,13 @@ export function AbsenceCard({
|
|||||||
id="absence-startDate"
|
id="absence-startDate"
|
||||||
name="startDate"
|
name="startDate"
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={today}
|
value={startDate}
|
||||||
|
min={today}
|
||||||
|
onChange={(event) => {
|
||||||
|
const date = event.target.value;
|
||||||
|
setStartDate(date);
|
||||||
|
if (date > endDate) setEndDate(date);
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
@@ -145,7 +166,9 @@ export function AbsenceCard({
|
|||||||
id="absence-endDate"
|
id="absence-endDate"
|
||||||
name="endDate"
|
name="endDate"
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={today}
|
value={endDate}
|
||||||
|
min={startDate}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
@@ -178,6 +201,7 @@ export function AbsenceCard({
|
|||||||
Abwesenheit melden
|
Abwesenheit melden
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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">
|
||||||
@@ -201,8 +225,12 @@ export function AbsenceCard({
|
|||||||
|
|
||||||
export function UpcomingAbsencesCard({
|
export function UpcomingAbsencesCard({
|
||||||
absences,
|
absences,
|
||||||
|
today,
|
||||||
|
team = false,
|
||||||
}: {
|
}: {
|
||||||
absences: AbsenceListItem[];
|
absences: AbsenceListItem[];
|
||||||
|
today: string;
|
||||||
|
team?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -233,16 +261,21 @@ export function UpcomingAbsencesCard({
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||||
{absences.map((absence) => (
|
{absences.map((absence) => (
|
||||||
<AbsenceRow key={absence.id} absence={absence} />
|
<AbsenceRow
|
||||||
|
key={`${absence.id}-${absence.updatedAt}`}
|
||||||
|
absence={absence}
|
||||||
|
today={today}
|
||||||
|
team={team}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/admin/abwesenheiten"
|
href="/dashboard/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)]"
|
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
|
Meine Abwesenheiten
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -272,7 +305,15 @@ function FieldLabel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
|
function AbsenceRow({
|
||||||
|
absence,
|
||||||
|
today,
|
||||||
|
team,
|
||||||
|
}: {
|
||||||
|
absence: AbsenceListItem;
|
||||||
|
today: string;
|
||||||
|
team: boolean;
|
||||||
|
}) {
|
||||||
const { day, month, detail } = useMemo(() => {
|
const { day, month, detail } = useMemo(() => {
|
||||||
const start = new Date(`${absence.startDate}T00:00:00`);
|
const start = new Date(`${absence.startDate}T00:00:00`);
|
||||||
const end = new Date(`${absence.endDate}T00:00:00`);
|
const end = new Date(`${absence.endDate}T00:00:00`);
|
||||||
@@ -327,9 +368,8 @@ function AbsenceRow({ absence }: { absence: AbsenceListItem }) {
|
|||||||
{absence.note}
|
{absence.note}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
<AbsenceManagementActions absence={absence} today={today} team={team} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
absenceDate,
|
||||||
|
absenceToday,
|
||||||
|
canManageKitaAbsences,
|
||||||
|
} from "@/lib/absence-policy";
|
||||||
|
import { reasonLabel } from "@/lib/absence-utils";
|
||||||
|
import { AbsenceManagementActions } from "@/components/absence-management-actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export const metadata = { title: "Meine Abwesenheiten · Kita-Planer" };
|
||||||
|
|
||||||
|
export default async function OwnAbsencesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ view?: string; page?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const actor = await prisma.user.findFirstOrThrow({
|
||||||
|
where: { id: session.user.id, kitaId: session.user.kitaId },
|
||||||
|
select: { familyId: true, role: true },
|
||||||
|
});
|
||||||
|
const query = await searchParams;
|
||||||
|
const past = query.view === "past";
|
||||||
|
const parsedPage = Number(query.page ?? 1);
|
||||||
|
const requestedPage =
|
||||||
|
Number.isSafeInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
|
||||||
|
const today = absenceToday();
|
||||||
|
const where = {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
child: { kitaId: session.user.kitaId, familyId: actor.familyId ?? "" },
|
||||||
|
endDate: past ? { lt: absenceDate(today) } : { gte: absenceDate(today) },
|
||||||
|
};
|
||||||
|
const count = actor.familyId ? await prisma.absence.count({ where }) : 0;
|
||||||
|
const pages = Math.max(1, Math.ceil(count / 20));
|
||||||
|
const page = Math.min(requestedPage, pages);
|
||||||
|
const absences = actor.familyId
|
||||||
|
? await prisma.absence.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
reason: true,
|
||||||
|
note: true,
|
||||||
|
updatedAt: true,
|
||||||
|
child: { select: { firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ startDate: past ? "desc" : "asc" }, { id: "asc" }],
|
||||||
|
take: 20,
|
||||||
|
skip: (page - 1) * 20,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const href = (nextPage: number) =>
|
||||||
|
`/dashboard/abwesenheiten?view=${past ? "past" : "current"}&page=${nextPage}`;
|
||||||
|
const dateLabel = (date: Date) =>
|
||||||
|
new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(date);
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1100px] px-11 py-9 max-[760px]:px-5">
|
||||||
|
<header className="mb-7">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
|
||||||
|
MEIN HAUSHALT
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-4xl tracking-tight">
|
||||||
|
Meine{" "}
|
||||||
|
<em className="font-normal text-[var(--accent-deep)]">
|
||||||
|
Abwesenheiten
|
||||||
|
</em>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
|
Zukünftige Meldungen deiner Kinder kannst du ändern oder zurücknehmen.
|
||||||
|
Sobald eine Meldung begonnen hat, übernimmt die Kita die gesamte
|
||||||
|
Meldung.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/dashboard">Kind abmelden</Link>
|
||||||
|
</Button>
|
||||||
|
{canManageKitaAbsences(actor.role) && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/dashboard/admin/abwesenheiten">
|
||||||
|
Kita-Tagesübersicht
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{!actor.familyId ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<p className="mb-3">
|
||||||
|
Deinem Konto ist noch kein Haushalt zugeordnet.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/dashboard/profil">Profil öffnen</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<nav aria-label="Abwesenheitszeitraum" className="mb-5 flex gap-2">
|
||||||
|
<Button asChild size="sm" variant={!past ? "default" : "outline"}>
|
||||||
|
<Link href="/dashboard/abwesenheiten">Aktuell & zukünftig</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant={past ? "default" : "outline"}>
|
||||||
|
<Link href="/dashboard/abwesenheiten?view=past">Vergangen</Link>
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
<p className="mb-3 text-sm text-[var(--ink-muted)]">
|
||||||
|
{count} Meldung{count === 1 ? "" : "en"}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{absences.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-sm text-[var(--ink-soft)]">
|
||||||
|
Für diesen Zeitraum sind keine Abwesenheiten gemeldet.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{absences.map((absence) => {
|
||||||
|
const startDate = absence.startDate.toISOString().slice(0, 10);
|
||||||
|
return (
|
||||||
|
<Card key={absence.id}>
|
||||||
|
<CardContent className="py-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
{absence.child.firstName} {absence.child.lastName}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
|
{dateLabel(absence.startDate)}
|
||||||
|
{absence.endDate.getTime() !==
|
||||||
|
absence.startDate.getTime()
|
||||||
|
? ` – ${dateLabel(absence.endDate)}`
|
||||||
|
: ""}{" "}
|
||||||
|
· {reasonLabel(absence.reason)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{past
|
||||||
|
? "Vergangen"
|
||||||
|
: startDate <= today
|
||||||
|
? "Laufend"
|
||||||
|
: "Zukünftig"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{absence.note && (
|
||||||
|
<p className="mt-3 whitespace-pre-wrap break-words text-sm text-[var(--ink-soft)]">
|
||||||
|
{absence.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<AbsenceManagementActions
|
||||||
|
key={`${absence.id}-${absence.updatedAt.toISOString()}`}
|
||||||
|
today={today}
|
||||||
|
team={canManageKitaAbsences(actor.role)}
|
||||||
|
absence={{
|
||||||
|
id: absence.id,
|
||||||
|
reason: absence.reason,
|
||||||
|
note: absence.note,
|
||||||
|
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||||
|
startDate,
|
||||||
|
endDate: absence.endDate.toISOString().slice(0, 10),
|
||||||
|
updatedAt: absence.updatedAt.toISOString(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{pages > 1 && (
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-3">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={href(page - 1)}>Zurück</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-[var(--ink-muted)]">
|
||||||
|
Seite {page} von {pages}
|
||||||
|
</span>
|
||||||
|
{page < pages ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={href(page + 1)}>Weiter</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { addDays, format, isWeekend, parseISO } from "date-fns";
|
import { addDays, format, parseISO } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -55,34 +55,23 @@ export function AbsencesClient({
|
|||||||
const today = parseISO(todayStr);
|
const today = parseISO(todayStr);
|
||||||
|
|
||||||
// Filters State
|
// Filters State
|
||||||
const [selectedGroup, setSelectedGroup] = useState<"all" | "Krippe" | "Elementar">("all");
|
|
||||||
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
|
const [selectedReason, setSelectedReason] = useState<"all" | AbsenceReason>("all");
|
||||||
|
|
||||||
// Dropdown UI state
|
// Dropdown UI state
|
||||||
const [isGroupOpen, setIsGroupOpen] = useState(false);
|
|
||||||
const [isReasonOpen, setIsReasonOpen] = useState(false);
|
const [isReasonOpen, setIsReasonOpen] = useState(false);
|
||||||
|
|
||||||
// Date input state
|
// Date input state
|
||||||
const [inputDate, setInputDate] = useState(dateKey);
|
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
|
// Filter computation
|
||||||
const filteredAbsences = useMemo(() => {
|
const filteredAbsences = useMemo(() => {
|
||||||
return initialAbsences.filter((item) => {
|
return initialAbsences.filter((item) => {
|
||||||
if (selectedGroup !== "all" && mockGroup(item.childName) !== selectedGroup) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (selectedReason !== "all" && item.reason !== selectedReason) {
|
if (selectedReason !== "all" && item.reason !== selectedReason) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [initialAbsences, selectedGroup, selectedReason]);
|
}, [initialAbsences, selectedReason]);
|
||||||
|
|
||||||
// Recalculate reasons count based on filtered absences
|
// Recalculate reasons count based on filtered absences
|
||||||
const countsByReason = useMemo(() => {
|
const countsByReason = useMemo(() => {
|
||||||
@@ -113,9 +102,9 @@ export function AbsencesClient({
|
|||||||
TAGESÜBERSICHT
|
TAGESÜBERSICHT
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||||
Heute fehlen{" "}
|
{isToday ? "Heute" : "An diesem Tag"}{filteredAbsences.length === 1 ? " fehlt " : " fehlen "}
|
||||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||||
{filteredAbsences.length} Kinder
|
{filteredAbsences.length} {filteredAbsences.length === 1 ? "Kind" : "Kinder"}
|
||||||
</em>
|
</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
|
<p className="mt-2 text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||||
@@ -126,7 +115,7 @@ export function AbsencesClient({
|
|||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
<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)]">
|
<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]" />
|
<Stethoscope className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||||
{childCount - filteredAbsences.length} anwesend
|
{Math.max(0, childCount - initialAbsences.length)} anwesend
|
||||||
</span>
|
</span>
|
||||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||||
<button
|
<button
|
||||||
@@ -167,71 +156,6 @@ export function AbsencesClient({
|
|||||||
|
|
||||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 */}
|
{/* Reason Dropdown Filter */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -361,7 +285,7 @@ export function AbsencesClient({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<AbsenceTable absences={filteredAbsences} />
|
<AbsenceTable absences={filteredAbsences} today={todayStr} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : isTooFarFuture ? (
|
) : isTooFarFuture ? (
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
|||||||
import { AbsenceReason } from "@prisma/client";
|
import { AbsenceReason } from "@prisma/client";
|
||||||
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
|
import { CalendarDays, Circle, MessageSquareText, UserRound } from "lucide-react";
|
||||||
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
import { reasonColor, reasonLabel } from "@/lib/absence-utils";
|
||||||
|
import { AbsenceManagementActions } from "@/components/absence-management-actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@@ -30,12 +32,12 @@ export type AbsenceTableItem = {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
export function AbsenceTable({ absences, today }: { absences: AbsenceTableItem[]; today: string }) {
|
||||||
const [activeAbsence, setActiveAbsence] = useState<AbsenceTableItem | null>(
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
null,
|
const activeAbsence = absences.find((absence) => absence.id === activeId) ?? null;
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -47,6 +49,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
|||||||
<TableHead>Grund</TableHead>
|
<TableHead>Grund</TableHead>
|
||||||
<TableHead>Bemerkung</TableHead>
|
<TableHead>Bemerkung</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead><span className="sr-only">Aktionen</span></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -54,7 +57,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={absence.id}
|
key={absence.id}
|
||||||
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
|
className="cursor-pointer border-[var(--hairline-soft)] hover:bg-[var(--surface-2)]"
|
||||||
onClick={() => setActiveAbsence(absence)}
|
onClick={() => setActiveId(absence.id)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -89,6 +92,9 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="success">Bestätigt</Badge>
|
<Badge variant="success">Bestätigt</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={(event) => { event.stopPropagation(); setActiveId(absence.id); }} aria-label={`Abwesenheit öffnen: ${absence.childName}`}>Details & bearbeiten</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -96,9 +102,9 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
|||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={!!activeAbsence}
|
open={!!activeAbsence}
|
||||||
onOpenChange={(open) => !open && setActiveAbsence(null)}
|
onOpenChange={(open) => !open && setActiveId(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">
|
<DialogContent className="left-auto right-0 top-0 h-full max-w-md overflow-y-auto 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 ? (
|
{activeAbsence ? (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -129,6 +135,7 @@ export function AbsenceTable({ absences }: { absences: AbsenceTableItem[] }) {
|
|||||||
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
|
value={activeAbsence.note || "Keine Bemerkung hinterlegt."}
|
||||||
/>
|
/>
|
||||||
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
|
<DetailLine icon={UserRound} label="Status" value="Bestätigt" />
|
||||||
|
<AbsenceManagementActions key={activeAbsence.updatedAt} absence={activeAbsence} today={today} team onChanged={() => setActiveId(null)} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns";
|
|
||||||
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
|
import { TerminStatus, TerminType, UserRole } from "@prisma/client";
|
||||||
|
|
||||||
import { getDailyAbsences } from "@/actions/absences";
|
import { getDailyAbsences } from "@/actions/absences";
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { absenceDate, absenceToday, isAbsenceDate } from "@/lib/absence-policy";
|
||||||
import { AbsencesClient } from "./_components/absences-client";
|
import { AbsencesClient } from "./_components/absences-client";
|
||||||
import { type AbsenceTableItem } from "./absence-table";
|
import { type AbsenceTableItem } from "./absence-table";
|
||||||
|
|
||||||
@@ -21,9 +21,10 @@ export default async function AdminAbwesenheitenPage({
|
|||||||
]);
|
]);
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = session.user.kitaId!;
|
||||||
const today = startOfLocalDay(new Date());
|
const todayKey = absenceToday();
|
||||||
|
const today = absenceDate(todayKey);
|
||||||
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
|
const selectedDate = parseDateParam(resolvedSearchParams.date, today);
|
||||||
const dateKey = format(selectedDate, "yyyy-MM-dd");
|
const dateKey = selectedDate.toISOString().slice(0, 10);
|
||||||
const result = await getDailyAbsences({ date: dateKey });
|
const result = await getDailyAbsences({ date: dateKey });
|
||||||
const absences = result.absences ?? [];
|
const absences = result.absences ?? [];
|
||||||
const childCount = await prisma.child.count({
|
const childCount = await prisma.child.count({
|
||||||
@@ -46,9 +47,9 @@ export default async function AdminAbwesenheitenPage({
|
|||||||
select: { title: true, type: true },
|
select: { title: true, type: true },
|
||||||
orderBy: { startDate: "asc" },
|
orderBy: { startDate: "asc" },
|
||||||
});
|
});
|
||||||
const isToday = dateKey === format(today, "yyyy-MM-dd");
|
const isToday = dateKey === todayKey;
|
||||||
const isTooFarFuture = differenceInCalendarDays(selectedDate, today) > 30;
|
const isTooFarFuture = (selectedDate.getTime() - today.getTime()) / 86_400_000 > 30;
|
||||||
const isClosedDay = isWeekend(selectedDate) || !!closure;
|
const isClosedDay = [0, 6].includes(selectedDate.getUTCDay()) || !!closure;
|
||||||
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
|
const tableItems: AbsenceTableItem[] = absences.map((absence) => ({
|
||||||
id: absence.id,
|
id: absence.id,
|
||||||
childName: absence.childName,
|
childName: absence.childName,
|
||||||
@@ -57,6 +58,7 @@ export default async function AdminAbwesenheitenPage({
|
|||||||
note: absence.note,
|
note: absence.note,
|
||||||
startDate: absence.startDate,
|
startDate: absence.startDate,
|
||||||
endDate: absence.endDate,
|
endDate: absence.endDate,
|
||||||
|
updatedAt: absence.updatedAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,11 +69,11 @@ export default async function AdminAbwesenheitenPage({
|
|||||||
upcomingAbsenceCount={upcomingAbsenceCount}
|
upcomingAbsenceCount={upcomingAbsenceCount}
|
||||||
closure={closure}
|
closure={closure}
|
||||||
dateKey={dateKey}
|
dateKey={dateKey}
|
||||||
selectedDateStr={selectedDate.toISOString()}
|
selectedDateStr={dateKey}
|
||||||
isToday={isToday}
|
isToday={isToday}
|
||||||
isTooFarFuture={isTooFarFuture}
|
isTooFarFuture={isTooFarFuture}
|
||||||
isClosedDay={isClosedDay}
|
isClosedDay={isClosedDay}
|
||||||
todayStr={today.toISOString()}
|
todayStr={todayKey}
|
||||||
error={result.error ?? null}
|
error={result.error ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,13 +81,5 @@ export default async function AdminAbwesenheitenPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseDateParam(value: string | undefined, fallback: Date) {
|
function parseDateParam(value: string | undefined, fallback: Date) {
|
||||||
if (!value) return fallback;
|
return value && isAbsenceDate(value) ? absenceDate(value) : fallback;
|
||||||
const parsed = new Date(`${value}T00:00:00`);
|
|
||||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startOfLocalDay(date: Date) {
|
|
||||||
const nextDate = new Date(date);
|
|
||||||
nextDate.setHours(0, 0, 0, 0);
|
|
||||||
return nextDate;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ArrowLeft, FileText, Mail } from "lucide-react";
|
||||||
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { announcementAudienceLabels } from "@/lib/announcement-access";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { EditNewsForm, RetryNewsEmails } from "../edit-news-form";
|
||||||
|
|
||||||
|
export const metadata = { title: "Mitteilung bearbeiten · Kita-Planer" };
|
||||||
|
export default async function EditNewsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await requireRole(["ADMIN"]);
|
||||||
|
const { id } = await params;
|
||||||
|
const announcement = await prisma.announcement.findFirst({
|
||||||
|
where: { id, kitaId: session.user.kitaId! },
|
||||||
|
include: {
|
||||||
|
attachments: { select: { id: true, fileName: true, fileUrl: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!announcement) notFound();
|
||||||
|
const rawPage = Number((await searchParams).page ?? 1);
|
||||||
|
const page =
|
||||||
|
Number.isSafeInteger(rawPage) && rawPage > 0
|
||||||
|
? Math.min(rawPage, 100000)
|
||||||
|
: 1;
|
||||||
|
const where = {
|
||||||
|
kitaId: session.user.kitaId!,
|
||||||
|
announcementId: id,
|
||||||
|
revision: announcement.revision,
|
||||||
|
};
|
||||||
|
const [statuses, emails, older] = await Promise.all([
|
||||||
|
prisma.announcementEmail.findMany({
|
||||||
|
where,
|
||||||
|
select: { sentAt: true, skippedAt: true, failed: true },
|
||||||
|
}),
|
||||||
|
prisma.announcementEmail.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
take: 51,
|
||||||
|
skip: (page - 1) * 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sentAt: true,
|
||||||
|
skippedAt: true,
|
||||||
|
failed: true,
|
||||||
|
attempts: true,
|
||||||
|
leaseUntil: true,
|
||||||
|
lastError: true,
|
||||||
|
user: { select: { firstName: true, lastName: true, email: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.announcementEmail.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId!,
|
||||||
|
announcementId: id,
|
||||||
|
revision: { lt: announcement.revision },
|
||||||
|
},
|
||||||
|
select: { revision: true, sentAt: true, skippedAt: true, failed: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const history = new Map<
|
||||||
|
number,
|
||||||
|
{ sent: number; skipped: number; failed: number }
|
||||||
|
>();
|
||||||
|
for (const job of older) {
|
||||||
|
const summary = history.get(job.revision) ?? {
|
||||||
|
sent: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
if (job.sentAt) summary.sent++;
|
||||||
|
else if (job.skippedAt) summary.skipped++;
|
||||||
|
else if (job.failed) summary.failed++;
|
||||||
|
history.set(job.revision, summary);
|
||||||
|
}
|
||||||
|
const backgroundConfigured =
|
||||||
|
(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32;
|
||||||
|
const sent = statuses.filter((job) => job.sentAt).length;
|
||||||
|
const skipped = statuses.filter((job) => job.skippedAt).length;
|
||||||
|
const failed = statuses.filter(
|
||||||
|
(job) => !job.sentAt && !job.skippedAt && job.failed,
|
||||||
|
).length;
|
||||||
|
const pending = statuses.length - sent - skipped - failed;
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="mb-5">
|
||||||
|
<Link href="/dashboard/admin/news#beitraege">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Alle Beiträge
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<header className="mb-7">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||||
|
SCHWARZES BRETT · BEITRAG
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
|
||||||
|
Mitteilung{" "}
|
||||||
|
<em className="font-normal text-[var(--accent-deep)]">bearbeiten</em>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-[var(--ink-soft)]">
|
||||||
|
Veröffentlicht am {format(announcement.createdAt, "dd.MM.yyyy HH:mm")}
|
||||||
|
{announcement.editedAt &&
|
||||||
|
` · Aktualisiert am ${format(announcement.editedAt, "dd.MM.yyyy HH:mm")}`}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Inhalt korrigieren</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Empfängergruppe und bestehende Anhänge bleiben bei einer Korrektur
|
||||||
|
erhalten.
|
||||||
|
</CardDescription>
|
||||||
|
<Badge variant="outline" className="mt-2 w-fit">
|
||||||
|
{announcementAudienceLabels[announcement.audience]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EditNewsForm
|
||||||
|
key={announcement.revision}
|
||||||
|
announcement={{
|
||||||
|
id,
|
||||||
|
revision: announcement.revision,
|
||||||
|
title: announcement.title,
|
||||||
|
content: announcement.content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{announcement.attachments.length > 0 && (
|
||||||
|
<div className="mt-6 border-t border-[var(--hairline)] pt-4">
|
||||||
|
<h2 className="mb-2 text-sm font-medium">Anhänge</h2>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{announcement.attachments.map((a) => (
|
||||||
|
<a
|
||||||
|
key={a.id}
|
||||||
|
href={a.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2 break-all text-sm text-[var(--accent-deep)] underline"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
|
{a.fileName}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card id="versand">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
E-Mail-Versand
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Status der aktuellen Fassung. „Gesendet“ bedeutet, dass der
|
||||||
|
Maildienst die Nachricht angenommen hat.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="success">{sent} gesendet</Badge>
|
||||||
|
<Badge variant="secondary">{pending} ausstehend</Badge>
|
||||||
|
<Badge variant="outline">{failed} fehlgeschlagen</Badge>
|
||||||
|
{skipped > 0 && (
|
||||||
|
<Badge variant="outline">{skipped} übersprungen</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!backgroundConfigured && (
|
||||||
|
<p className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm leading-6">
|
||||||
|
Automatische Wiederholungen sind noch nicht eingerichtet. Bitte
|
||||||
|
wende dich an die Hosting-Administration; fehlgeschlagene
|
||||||
|
E-Mails kannst du hier selbst erneut versuchen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!announcement.emailReady && (
|
||||||
|
<p className="text-sm text-[var(--ink-soft)]">
|
||||||
|
Die Anhänge werden noch vorbereitet. Der Versand beginnt erst,
|
||||||
|
wenn alle Dateien gespeichert sind.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
Bei eingerichteter Automatik werden fehlgeschlagene Sendungen
|
||||||
|
erneut versucht. Deaktivierte Konten und Personen, die nicht mehr
|
||||||
|
zur Empfängergruppe gehören, werden übersprungen.
|
||||||
|
</p>
|
||||||
|
{failed > 0 && (
|
||||||
|
<div className="[&_button]:h-auto [&_button]:whitespace-normal">
|
||||||
|
<RetryNewsEmails
|
||||||
|
id={id}
|
||||||
|
revision={announcement.revision}
|
||||||
|
failed={failed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="justify-self-start"
|
||||||
|
>
|
||||||
|
<Link href={`/dashboard/admin/news/${id}?page=${page}#versand`}>
|
||||||
|
Status aktualisieren
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{statuses.length === 0 && (
|
||||||
|
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
|
Für diese Fassung sind keine E-Mails vorgemerkt. Bei Beiträgen
|
||||||
|
aus der Zeit vor Einführung dieser Übersicht liegt kein
|
||||||
|
damaliger Versandstatus vor. Über das Formular kannst du die
|
||||||
|
aktuelle Fassung einmalig versenden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{emails.slice(0, 50).map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
className="min-w-0 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{job.user.firstName} {job.user.lastName}
|
||||||
|
</p>
|
||||||
|
<Badge variant={job.sentAt ? "success" : "outline"}>
|
||||||
|
{job.sentAt
|
||||||
|
? "Gesendet"
|
||||||
|
: job.skippedAt
|
||||||
|
? "Übersprungen"
|
||||||
|
: job.failed
|
||||||
|
? "Fehlgeschlagen"
|
||||||
|
: job.leaseUntil && job.leaseUntil > new Date()
|
||||||
|
? "Wird versendet"
|
||||||
|
: "Ausstehend"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 break-all text-xs text-[var(--ink-muted)]">
|
||||||
|
{job.user.email}
|
||||||
|
</p>
|
||||||
|
{job.sentAt && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--ink-muted)]">
|
||||||
|
{format(job.sentAt, "dd.MM.yyyy HH:mm")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{job.lastError && (
|
||||||
|
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
{job.lastError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{history.size > 0 && (
|
||||||
|
<details className="rounded-lg border border-[var(--hairline)] p-3">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">
|
||||||
|
Versand früherer Fassungen
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{[...history]
|
||||||
|
.sort(([a], [b]) => b - a)
|
||||||
|
.map(([revision, summary]) => (
|
||||||
|
<p
|
||||||
|
key={revision}
|
||||||
|
className="text-xs leading-5 text-[var(--ink-soft)]"
|
||||||
|
>
|
||||||
|
Fassung {revision}: {summary.sent} gesendet ·{" "}
|
||||||
|
{summary.skipped} übersprungen · {summary.failed}{" "}
|
||||||
|
fehlgeschlagen
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
|
||||||
|
Ungesendete E-Mails älterer Fassungen wurden durch die
|
||||||
|
Korrektur ersetzt.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{(page > 1 || emails.length > 50) && (
|
||||||
|
<nav
|
||||||
|
aria-label="Empfänger durchblättern"
|
||||||
|
className="flex flex-wrap justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{page > 1 && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`?page=${page - 1}#versand`}>Zurück</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">Seite {page}</span>
|
||||||
|
<div>
|
||||||
|
{emails.length > 50 && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={`?page=${page + 1}#versand`}>Weitere</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, RotateCw, Save } from "lucide-react";
|
||||||
|
import {
|
||||||
|
updateAnnouncement,
|
||||||
|
retryAnnouncementEmails,
|
||||||
|
} from "@/actions/announcements";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { MarkdownContent } from "@/components/markdown-content";
|
||||||
|
|
||||||
|
export function EditNewsForm({
|
||||||
|
announcement,
|
||||||
|
}: {
|
||||||
|
announcement: {
|
||||||
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [content, setContent] = useState(announcement.content);
|
||||||
|
const [sendEmail, setSendEmail] = useState(false);
|
||||||
|
function submit(formData: FormData) {
|
||||||
|
formData.set("id", announcement.id);
|
||||||
|
formData.set("revision", String(announcement.revision));
|
||||||
|
formData.set("sendEmail", sendEmail ? "on" : "off");
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await updateAnnouncement(formData);
|
||||||
|
if (!("success" in result)) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
result.queued
|
||||||
|
? `Mitteilung gespeichert. ${result.queued} E-Mails sind zum Versand vorgemerkt.`
|
||||||
|
: result.changed
|
||||||
|
? "Mitteilung aktualisiert."
|
||||||
|
: "Keine Änderungen vorhanden.",
|
||||||
|
);
|
||||||
|
setSendEmail(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form action={submit} className="grid gap-5">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-title">Titel</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
name="title"
|
||||||
|
defaultValue={announcement.title}
|
||||||
|
minLength={3}
|
||||||
|
maxLength={160}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-content">Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-content"
|
||||||
|
name="content"
|
||||||
|
rows={10}
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
minLength={3}
|
||||||
|
maxLength={20_000}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
|
Markdown ist aktiv. HTML wird nicht interpretiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<details className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">
|
||||||
|
Vorschau ansehen
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4 text-sm">
|
||||||
|
<MarkdownContent content={content} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="correction-email"
|
||||||
|
checked={sendEmail}
|
||||||
|
disabled={pending}
|
||||||
|
onCheckedChange={(checked) => setSendEmail(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="correction-email"
|
||||||
|
className="cursor-pointer leading-5"
|
||||||
|
>
|
||||||
|
Aktuelle Fassung auch per E-Mail senden
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
Bei einer Textänderung erscheint der Beitrag für alle wieder als
|
||||||
|
ungelesen. Jede Fassung wird je Person höchstens einmal zum Versand
|
||||||
|
vorgemerkt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={pending} className="justify-self-end">
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Änderungen speichern
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetryNewsEmails({
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
failed,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
failed: number;
|
||||||
|
}) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending || failed === 0}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await retryAnnouncementEmails(id, revision);
|
||||||
|
if (!("success" in result)) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
result.queued
|
||||||
|
? `${result.queued} fehlgeschlagene E-Mails werden erneut versucht.`
|
||||||
|
: "Keine fehlgeschlagenen Sendungen vorhanden.",
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("Versand konnte nicht gestartet werden.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Fehlgeschlagene erneut versuchen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useRef, useState, useTransition } from "react";
|
||||||
import { Loader2, Send, UploadCloud } from "lucide-react";
|
import { Loader2, Send, UploadCloud } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { MarkdownContent } from "@/components/markdown-content";
|
import { MarkdownContent } from "@/components/markdown-content";
|
||||||
|
|
||||||
export function NewsForm() {
|
export function NewsForm() {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [sendEmail, setSendEmail] = useState(false);
|
const [sendEmail, setSendEmail] = useState(false);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
@@ -30,6 +31,7 @@ export function NewsForm() {
|
|||||||
formData.set("sendEmail", "on");
|
formData.set("sendEmail", "on");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await createAnnouncement(formData);
|
const result = await createAnnouncement(formData);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
@@ -39,18 +41,33 @@ export function NewsForm() {
|
|||||||
if (result.warning) {
|
if (result.warning) {
|
||||||
toast.warning(result.warning);
|
toast.warning(result.warning);
|
||||||
} else {
|
} else {
|
||||||
toast.success("Ankündigung veröffentlicht.");
|
toast.success(
|
||||||
|
sendEmail
|
||||||
|
? "Ankündigung veröffentlicht. E-Mails sind zum Versand vorgemerkt."
|
||||||
|
: "Ankündigung veröffentlicht.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
formRef.current?.reset();
|
||||||
|
setContent("");
|
||||||
|
setSendEmail(false);
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
"Ankündigung konnte nicht veröffentlicht werden. Bitte versuche es erneut.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={handleSubmit} className="grid gap-5">
|
<form ref={formRef} action={handleSubmit} className="grid gap-5">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="news-title">Titel</Label>
|
<Label htmlFor="news-title">Titel</Label>
|
||||||
<Input
|
<Input
|
||||||
id="news-title"
|
id="news-title"
|
||||||
name="title"
|
name="title"
|
||||||
|
minLength={3}
|
||||||
|
maxLength={160}
|
||||||
|
disabled={isPending}
|
||||||
placeholder="Sommerfest: Aufbau und Mitbringliste"
|
placeholder="Sommerfest: Aufbau und Mitbringliste"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -62,6 +79,9 @@ export function NewsForm() {
|
|||||||
id="news-content"
|
id="news-content"
|
||||||
name="content"
|
name="content"
|
||||||
rows={10}
|
rows={10}
|
||||||
|
minLength={3}
|
||||||
|
maxLength={20_000}
|
||||||
|
disabled={isPending}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { FileText, Megaphone, Send } from "lucide-react";
|
import { FileText, Megaphone, Send } from "lucide-react";
|
||||||
@@ -18,7 +22,11 @@ import { NewsForm } from "./news-form";
|
|||||||
|
|
||||||
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
||||||
|
|
||||||
export default async function AdminNewsPage() {
|
export default async function AdminNewsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ q?: string; page?: string }>;
|
||||||
|
}) {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId;
|
||||||
|
|
||||||
@@ -26,29 +34,46 @@ export default async function AdminNewsPage() {
|
|||||||
throw new Error("Kein Mandant zugeordnet.");
|
throw new Error("Kein Mandant zugeordnet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const announcements = await prisma.announcement.findMany({
|
const params = await searchParams;
|
||||||
where: { kitaId },
|
const q = typeof params.q === "string" ? params.q.trim().slice(0, 160) : "";
|
||||||
|
const parsedPage = Number(params.page ?? 1);
|
||||||
|
const page =
|
||||||
|
Number.isSafeInteger(parsedPage) && parsedPage > 0
|
||||||
|
? Math.min(parsedPage, 100000)
|
||||||
|
: 1;
|
||||||
|
const pageSize = 12;
|
||||||
|
const where = {
|
||||||
|
kitaId,
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: q, mode: "insensitive" as const } },
|
||||||
|
{ content: { contains: q, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const [announcements, total] = await Promise.all([
|
||||||
|
prisma.announcement.findMany({
|
||||||
|
where,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
audience: true,
|
audience: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
attachments: {
|
editedAt: true,
|
||||||
select: {
|
revision: true,
|
||||||
id: true,
|
_count: { select: { attachments: true } },
|
||||||
fileName: true,
|
author: { select: { firstName: true, lastName: true } },
|
||||||
},
|
},
|
||||||
},
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
author: {
|
skip: (page - 1) * pageSize,
|
||||||
select: {
|
take: pageSize,
|
||||||
firstName: true,
|
}),
|
||||||
lastName: true,
|
prisma.announcement.count({ where }),
|
||||||
},
|
]);
|
||||||
},
|
const href = (p: number) =>
|
||||||
},
|
`/dashboard/admin/news?${new URLSearchParams({ ...(q ? { q } : {}), page: String(p) })}#beitraege`;
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 8,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
@@ -60,7 +85,7 @@ export default async function AdminNewsPage() {
|
|||||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||||
Meldungen{" "}
|
Meldungen{" "}
|
||||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||||
veröffentlichen
|
verwalten
|
||||||
</em>
|
</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||||
@@ -71,12 +96,19 @@ export default async function AdminNewsPage() {
|
|||||||
</div>
|
</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">
|
<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]" />
|
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||||
{announcements.length} zuletzt
|
{total} {total === 1 ? "Beitrag" : "Beiträge"}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
<div className="grid gap-6">
|
||||||
<Card>
|
<details
|
||||||
|
open={!q && page === 1}
|
||||||
|
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5"
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer text-lg font-semibold">
|
||||||
|
Neue Ankündigung veröffentlichen
|
||||||
|
</summary>
|
||||||
|
<Card className="mt-4 border-0 shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
|
||||||
@@ -91,22 +123,50 @@ export default async function AdminNewsPage() {
|
|||||||
<NewsForm />
|
<NewsForm />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</details>
|
||||||
|
|
||||||
<Card className="h-fit">
|
<Card id="beitraege" className="h-fit">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
|
<CardTitle className="text-base">
|
||||||
|
Veröffentlichte Beiträge
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Die neuesten Meldungen auf dem Schwarzen Brett.
|
Korrigiere auch ältere Beiträge und prüfe den E-Mail-Versand.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3">
|
<CardContent className="grid gap-3">
|
||||||
|
<form
|
||||||
|
className="mb-4 flex flex-wrap items-end gap-3"
|
||||||
|
action="/dashboard/admin/news#beitraege"
|
||||||
|
>
|
||||||
|
<div className="grid flex-1 gap-2">
|
||||||
|
<Label htmlFor="news-search">Beiträge durchsuchen</Label>
|
||||||
|
<Input
|
||||||
|
id="news-search"
|
||||||
|
name="q"
|
||||||
|
defaultValue={q}
|
||||||
|
maxLength={160}
|
||||||
|
placeholder="Titel oder Text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
{q && (
|
||||||
|
<Button asChild variant="ghost">
|
||||||
|
<Link href="/dashboard/admin/news#beitraege">
|
||||||
|
Alle Beiträge
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
{announcements.length === 0 ? (
|
{announcements.length === 0 ? (
|
||||||
<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="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)]">
|
<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]" />
|
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-[var(--ink)]">
|
<p className="text-sm font-medium text-[var(--ink)]">
|
||||||
Noch keine Ankündigungen
|
Keine Beiträge gefunden
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
Veröffentlichte Meldungen erscheinen hier.
|
Veröffentlichte Meldungen erscheinen hier.
|
||||||
@@ -119,13 +179,13 @@ export default async function AdminNewsPage() {
|
|||||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] 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">
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<p className="font-medium leading-snug text-[var(--ink)]">
|
<p className="min-w-0 break-words font-medium leading-snug text-[var(--ink)]">
|
||||||
{announcement.title}
|
{announcement.title}
|
||||||
</p>
|
</p>
|
||||||
{announcement.attachments.length > 0 && (
|
{announcement._count.attachments > 0 && (
|
||||||
<Badge variant="secondary" className="shrink-0">
|
<Badge variant="secondary" className="shrink-0">
|
||||||
<FileText className="h-3 w-3 [stroke-width:1.7]" />
|
<FileText className="h-3 w-3 [stroke-width:1.7]" />
|
||||||
{announcement.attachments.length}
|
{announcement._count.attachments}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,9 +199,42 @@ export default async function AdminNewsPage() {
|
|||||||
· {announcement.author.firstName}{" "}
|
· {announcement.author.firstName}{" "}
|
||||||
{announcement.author.lastName}
|
{announcement.author.lastName}
|
||||||
</p>
|
</p>
|
||||||
|
{announcement.editedAt && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--accent-deep)]">
|
||||||
|
Aktualisiert am{" "}
|
||||||
|
{format(announcement.editedAt, "dd.MM.yyyy HH:mm")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button asChild variant="outline" size="sm" className="mt-3">
|
||||||
|
<Link href={`/dashboard/admin/news/${announcement.id}`}>
|
||||||
|
Bearbeiten & Versand
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
<nav
|
||||||
|
aria-label="Beiträge durchblättern"
|
||||||
|
className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline)] pt-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{page > 1 && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={href(page - 1)}>Neuere Beiträge</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-[var(--ink-soft)]">
|
||||||
|
Seite {page} · {total} {total === 1 ? "Beitrag" : "Beiträge"}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
{page * pageSize < total && (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href={href(page + 1)}>Ältere Beiträge</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RoleChangeButton,
|
||||||
|
type RolePerson,
|
||||||
|
} from "../../zugriffsrechte/role-change-button";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -47,6 +51,7 @@ type EducatorDto = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
inviteFailed: boolean;
|
inviteFailed: boolean;
|
||||||
|
account: RolePerson | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AccessFields({
|
function AccessFields({
|
||||||
@@ -119,9 +124,11 @@ function AccessFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErzieherList({
|
export function ErzieherList({
|
||||||
|
currentUserId,
|
||||||
educators,
|
educators,
|
||||||
today,
|
today,
|
||||||
}: {
|
}: {
|
||||||
|
currentUserId: string;
|
||||||
educators: EducatorDto[];
|
educators: EducatorDto[];
|
||||||
today: string;
|
today: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -377,6 +384,14 @@ export function ErzieherList({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="pr-6 text-right">
|
<TableCell className="pr-6 text-right">
|
||||||
|
{ed.account ? (
|
||||||
|
<span className="mr-2 inline-block">
|
||||||
|
<RoleChangeButton
|
||||||
|
person={ed.account}
|
||||||
|
ownAccount={ed.account.id === currentUserId}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ export default async function ErzieherPage() {
|
|||||||
staffProfile: {
|
staffProfile: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
user: { select: { email: true, emailVerifiedAt: true } },
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
familyId: true,
|
||||||
|
email: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
emails: {
|
emails: {
|
||||||
where: { inviteToken: { not: null }, skippedAt: null },
|
where: { inviteToken: { not: null }, skippedAt: null },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@@ -65,10 +73,20 @@ export default async function ErzieherPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ErzieherList
|
<ErzieherList
|
||||||
|
currentUserId={session.user.id}
|
||||||
today={todayKey()}
|
today={todayKey()}
|
||||||
educators={educators.map(({ staffProfile, ...educator }) => ({
|
educators={educators.map(({ staffProfile, ...educator }) => ({
|
||||||
...educator,
|
...educator,
|
||||||
hasProfile: !!staffProfile,
|
hasProfile: !!staffProfile,
|
||||||
|
account: staffProfile?.user
|
||||||
|
? {
|
||||||
|
id: staffProfile.user.id,
|
||||||
|
name: `${educator.firstName} ${educator.lastName}`,
|
||||||
|
role: staffProfile.user.role,
|
||||||
|
familyId: staffProfile.user.familyId,
|
||||||
|
hasStaffProfile: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
email: staffProfile?.user?.email ?? null,
|
email: staffProfile?.user?.email ?? null,
|
||||||
verified: !!staffProfile?.user?.emailVerifiedAt,
|
verified: !!staffProfile?.user?.emailVerifiedAt,
|
||||||
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { UserRole } from "@prisma/client";
|
||||||
|
import { RoleChangeButton } from "../../zugriffsrechte/role-change-button";
|
||||||
|
import { RoleBadge } from "@/components/ui/role-badge";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -19,6 +22,8 @@ import {
|
|||||||
import { AddFamilyDialog } from "../add-family-dialog";
|
import { AddFamilyDialog } from "../add-family-dialog";
|
||||||
import { DutyManager } from "../duty-manager";
|
import { DutyManager } from "../duty-manager";
|
||||||
import { EditFamilyDialog } from "../edit-family-dialog";
|
import { EditFamilyDialog } from "../edit-family-dialog";
|
||||||
|
import { InvitationStatus } from "../invitation-status";
|
||||||
|
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
|
||||||
|
|
||||||
interface FamilyUser {
|
interface FamilyUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,6 +31,9 @@ interface FamilyUser {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
emailVerifiedAt: Date | null;
|
emailVerifiedAt: Date | null;
|
||||||
|
invitation: FamilyInvitationView;
|
||||||
|
role: UserRole;
|
||||||
|
staffProfile: { id: string } | null;
|
||||||
dutyAssignments: {
|
dutyAssignments: {
|
||||||
id: string;
|
id: string;
|
||||||
dutyId: string;
|
dutyId: string;
|
||||||
@@ -57,12 +65,14 @@ interface DutyData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FamiliesListClientProps {
|
interface FamiliesListClientProps {
|
||||||
|
currentUserId: string;
|
||||||
initialFamilies: FamilyData[];
|
initialFamilies: FamilyData[];
|
||||||
allDuties: DutyData[];
|
allDuties: DutyData[];
|
||||||
canManageDuties: boolean;
|
canManageDuties: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FamiliesListClient({
|
export function FamiliesListClient({
|
||||||
|
currentUserId,
|
||||||
initialFamilies,
|
initialFamilies,
|
||||||
allDuties,
|
allDuties,
|
||||||
canManageDuties,
|
canManageDuties,
|
||||||
@@ -82,7 +92,7 @@ export function FamiliesListClient({
|
|||||||
(user) =>
|
(user) =>
|
||||||
user.firstName.toLowerCase().includes(q) ||
|
user.firstName.toLowerCase().includes(q) ||
|
||||||
user.lastName.toLowerCase().includes(q) ||
|
user.lastName.toLowerCase().includes(q) ||
|
||||||
user.email.toLowerCase().includes(q)
|
user.email.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
if (matchesParent) return true;
|
if (matchesParent) return true;
|
||||||
|
|
||||||
@@ -90,20 +100,20 @@ export function FamiliesListClient({
|
|||||||
const matchesChild = family.children.some(
|
const matchesChild = family.children.some(
|
||||||
(child) =>
|
(child) =>
|
||||||
child.firstName.toLowerCase().includes(q) ||
|
child.firstName.toLowerCase().includes(q) ||
|
||||||
child.lastName.toLowerCase().includes(q)
|
child.lastName.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
return matchesChild;
|
return matchesChild;
|
||||||
});
|
});
|
||||||
|
|
||||||
const childCount = filteredFamilies.reduce(
|
const childCount = filteredFamilies.reduce(
|
||||||
(acc, family) => acc + family.children.length,
|
(acc, family) => acc + family.children.length,
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingInviteCount = filteredFamilies.reduce(
|
const pendingInviteCount = filteredFamilies.reduce(
|
||||||
(acc, family) =>
|
(acc, family) =>
|
||||||
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
|
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,14 +130,17 @@ export function FamiliesListClient({
|
|||||||
</em>
|
</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
<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.
|
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
|
||||||
|
pflegen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
|
onClick={() =>
|
||||||
|
setViewMode(viewMode === "table" ? "cards" : "table")
|
||||||
|
}
|
||||||
className="flex items-center gap-1.5"
|
className="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
@@ -149,7 +162,9 @@ export function FamiliesListClient({
|
|||||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<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="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>
|
<span className="text-[var(--ink)] tabular-nums">
|
||||||
|
{filteredFamilies.length}
|
||||||
|
</span>
|
||||||
Familien
|
Familien
|
||||||
</span>
|
</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="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)]">
|
||||||
@@ -157,7 +172,9 @@ export function FamiliesListClient({
|
|||||||
Kinder
|
Kinder
|
||||||
</span>
|
</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="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>
|
<span className="text-[var(--ink)] tabular-nums">
|
||||||
|
{pendingInviteCount}
|
||||||
|
</span>
|
||||||
Offene Einladungen
|
Offene Einladungen
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +210,8 @@ export function FamiliesListClient({
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle>Haushalte</CardTitle>
|
<CardTitle>Haushalte</CardTitle>
|
||||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||||
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
{filteredFamilies.length}{" "}
|
||||||
|
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
|
||||||
{childCount} Kinder · {pendingInviteCount} offene Einladungen
|
{childCount} Kinder · {pendingInviteCount} offene Einladungen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +233,7 @@ export function FamiliesListClient({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredFamilies.map((family) => {
|
{filteredFamilies.map((family) => {
|
||||||
const hasPendingInvite = family.users.some(
|
const hasPendingInvite = family.users.some(
|
||||||
(user) => !user.emailVerifiedAt
|
(user) => !user.emailVerifiedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -247,9 +265,29 @@ export function FamiliesListClient({
|
|||||||
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
|
||||||
<span className="break-words [overflow-wrap:anywhere]">
|
<span className="break-words [overflow-wrap:anywhere]">
|
||||||
{parent.email}
|
{parent.email}
|
||||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
{!parent.emailVerifiedAt && (
|
||||||
|
<ResendInviteButton userId={parent.id} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<InvitationStatus
|
||||||
|
userId={parent.id}
|
||||||
|
name={`${parent.firstName} ${parent.lastName}`}
|
||||||
|
invitation={parent.invitation}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<RoleBadge role={parent.role} />
|
||||||
|
<RoleChangeButton
|
||||||
|
ownAccount={parent.id === currentUserId}
|
||||||
|
person={{
|
||||||
|
id: parent.id,
|
||||||
|
name: `${parent.firstName} ${parent.lastName}`,
|
||||||
|
role: parent.role,
|
||||||
|
familyId: family.id,
|
||||||
|
hasStaffProfile: !!parent.staffProfile,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +311,9 @@ export function FamiliesListClient({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={hasPendingInvite ? "warning" : "success"}>
|
<Badge
|
||||||
|
variant={hasPendingInvite ? "warning" : "success"}
|
||||||
|
>
|
||||||
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -288,6 +328,7 @@ export function FamiliesListClient({
|
|||||||
firstName: parent.firstName,
|
firstName: parent.firstName,
|
||||||
lastName: parent.lastName,
|
lastName: parent.lastName,
|
||||||
email: parent.email,
|
email: parent.email,
|
||||||
|
invitation: parent.invitation,
|
||||||
})),
|
})),
|
||||||
children: family.children,
|
children: family.children,
|
||||||
}}
|
}}
|
||||||
@@ -318,7 +359,7 @@ export function FamiliesListClient({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredFamilies.map((family) => {
|
{filteredFamilies.map((family) => {
|
||||||
const hasPendingInvite = family.users.some(
|
const hasPendingInvite = family.users.some(
|
||||||
(user) => !user.emailVerifiedAt
|
(user) => !user.emailVerifiedAt,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Card key={family.id} className="flex flex-col h-full">
|
<Card key={family.id} className="flex flex-col h-full">
|
||||||
@@ -328,9 +369,12 @@ export function FamiliesListClient({
|
|||||||
{family.name.slice(0, 1)}
|
{family.name.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle>
|
<CardTitle className="text-lg font-semibold">
|
||||||
|
{family.name}
|
||||||
|
</CardTitle>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
|
{family.users.length} Elternteil
|
||||||
|
{family.users.length === 1 ? "" : "e"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,9 +398,29 @@ export function FamiliesListClient({
|
|||||||
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
|
||||||
<span className="break-words [overflow-wrap:anywhere]">
|
<span className="break-words [overflow-wrap:anywhere]">
|
||||||
{parent.email}
|
{parent.email}
|
||||||
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
|
{!parent.emailVerifiedAt && (
|
||||||
|
<ResendInviteButton userId={parent.id} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<InvitationStatus
|
||||||
|
userId={parent.id}
|
||||||
|
name={`${parent.firstName} ${parent.lastName}`}
|
||||||
|
invitation={parent.invitation}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<RoleBadge role={parent.role} />
|
||||||
|
<RoleChangeButton
|
||||||
|
ownAccount={parent.id === currentUserId}
|
||||||
|
person={{
|
||||||
|
id: parent.id,
|
||||||
|
name: `${parent.firstName} ${parent.lastName}`,
|
||||||
|
role: parent.role,
|
||||||
|
familyId: family.id,
|
||||||
|
hasStaffProfile: !!parent.staffProfile,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -393,6 +457,7 @@ export function FamiliesListClient({
|
|||||||
firstName: parent.firstName,
|
firstName: parent.firstName,
|
||||||
lastName: parent.lastName,
|
lastName: parent.lastName,
|
||||||
email: parent.email,
|
email: parent.email,
|
||||||
|
invitation: parent.invitation,
|
||||||
})),
|
})),
|
||||||
children: family.children,
|
children: family.children,
|
||||||
}}
|
}}
|
||||||
@@ -423,8 +488,21 @@ export function FamiliesListClient({
|
|||||||
|
|
||||||
function ResendInviteButton({ userId }: { userId: string }) {
|
function ResendInviteButton({ userId }: { userId: string }) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
const result = await resendParentInvite(userId);
|
const result = await resendParentInvite(userId);
|
||||||
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
|
if (result.error) toast.error(result.error);
|
||||||
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
|
else toast.success("Einladung erneut gesendet.");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,60 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { hashToken, newToken } from "@/lib/tokens";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { Prisma, UserRole } from "@prisma/client";
|
import { Prisma, UserRole } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { InviteEmail } from "@/emails/InviteEmail";
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import {
|
||||||
|
deliverFamilyInvitations,
|
||||||
|
queueFamilyInvitation,
|
||||||
|
resendFamilyInvitation,
|
||||||
|
} from "@/lib/family-invitations";
|
||||||
|
import {
|
||||||
|
assertKitaAdminRemains,
|
||||||
|
lockKitaAccounts,
|
||||||
|
} from "@/lib/kita-admin-guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||||
const INVITE_TOKEN_TTL_DAYS = 7;
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
const BASE_URL =
|
|
||||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
|
||||||
|
|
||||||
const childSchema = z.object({
|
const childSchema = z.object({
|
||||||
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
|
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
|
||||||
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
|
lastName: z.string().trim().min(1, "Nachname des Kindes fehlt.").max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parentInputSchema = z.object({
|
const parentInputSchema = z.object({
|
||||||
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
|
||||||
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim(),
|
.email("Bitte eine gültige E-Mail-Adresse angeben."),
|
||||||
});
|
});
|
||||||
|
|
||||||
const addFamilySchema = z.object({
|
const addFamilySchema = z.object({
|
||||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
familyName: z
|
||||||
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
|
.string()
|
||||||
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
|
.trim()
|
||||||
|
.min(1, "Familienname ist erforderlich.")
|
||||||
|
.max(120),
|
||||||
|
parent1FirstName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Vorname ist erforderlich.")
|
||||||
|
.max(100),
|
||||||
|
parent1LastName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Nachname ist erforderlich.")
|
||||||
|
.max(100),
|
||||||
parent1Email: z
|
parent1Email: z
|
||||||
.string()
|
.string()
|
||||||
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim(),
|
.email("Bitte eine gültige E-Mail-Adresse angeben."),
|
||||||
parent2FirstName: z.string().max(100).trim().optional(),
|
parent2FirstName: z.string().max(100).trim().optional(),
|
||||||
parent2LastName: z.string().max(100).trim().optional(),
|
parent2LastName: z.string().max(100).trim().optional(),
|
||||||
parent2Email: z.string().trim().optional(),
|
parent2Email: z.string().trim().optional(),
|
||||||
@@ -52,12 +67,19 @@ const addFamilySchema = z.object({
|
|||||||
|
|
||||||
const updateFamilySchema = z.object({
|
const updateFamilySchema = z.object({
|
||||||
familyId: z.string().min(1),
|
familyId: z.string().min(1),
|
||||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
familyName: z
|
||||||
parents: z.array(
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Familienname ist erforderlich.")
|
||||||
|
.max(120),
|
||||||
|
parents: z
|
||||||
|
.array(
|
||||||
parentInputSchema.extend({
|
parentInputSchema.extend({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
).min(1).max(2),
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(2),
|
||||||
newParents: z.array(parentInputSchema).max(1).default([]),
|
newParents: z.array(parentInputSchema).max(1).default([]),
|
||||||
children: z
|
children: z
|
||||||
.array(
|
.array(
|
||||||
@@ -83,12 +105,7 @@ export type AddFamilyState = {
|
|||||||
_form?: string[];
|
_form?: string[];
|
||||||
};
|
};
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
};
|
warning?: string;
|
||||||
|
|
||||||
type InviteJob = {
|
|
||||||
to: string;
|
|
||||||
parentName: string;
|
|
||||||
inviteUrl: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
||||||
@@ -115,32 +132,14 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
|
|||||||
return { ok: true as const, parent: parsed.data };
|
return { ok: true as const, parent: parsed.data };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInviteToken() {
|
async function sendInviteJobs(ids: string[]) {
|
||||||
const token = newToken();
|
try {
|
||||||
const expires = new Date(
|
const result = await deliverFamilyInvitations(ids);
|
||||||
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
|
if (!result.failed) return undefined;
|
||||||
);
|
} catch {
|
||||||
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
|
// A committed family remains successful even if delivery/status storage fails.
|
||||||
}
|
}
|
||||||
|
return "Familie gespeichert. Mindestens eine Einladung konnte nicht versendet werden. Den Versand kannst du beim jeweiligen Elternteil erneut versuchen.";
|
||||||
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
|
|
||||||
for (const job of jobs) {
|
|
||||||
const result = await sendAppEmail({
|
|
||||||
to: job.to,
|
|
||||||
subject: `Einladung zu ${kitaName} im Kita-Planer`,
|
|
||||||
react: createElement(InviteEmail, {
|
|
||||||
parentName: job.parentName,
|
|
||||||
kitaName,
|
|
||||||
inviteLink: job.inviteUrl,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return result.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFamilyAction(
|
export async function addFamilyAction(
|
||||||
@@ -154,7 +153,12 @@ export async function addFamilyAction(
|
|||||||
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
|
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
_form: ["Zu viele Einladungen. Bitte versuche es später erneut."],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
|
||||||
if (!parsedFamily.success) {
|
if (!parsedFamily.success) {
|
||||||
@@ -185,11 +189,6 @@ export async function addFamilyAction(
|
|||||||
childrenRaw.push(parsed.data);
|
childrenRaw.push(parsed.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailConfigError = getAppEmailConfigError();
|
|
||||||
if (mailConfigError) {
|
|
||||||
return { errors: { _form: [mailConfigError] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const kita = await prisma.kita.findUnique({
|
const kita = await prisma.kita.findUnique({
|
||||||
where: { id: kitaId },
|
where: { id: kitaId },
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
@@ -211,12 +210,14 @@ export async function addFamilyAction(
|
|||||||
if (emailSet.size !== parents.length) {
|
if (emailSet.size !== parents.length) {
|
||||||
return {
|
return {
|
||||||
errors: {
|
errors: {
|
||||||
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
|
_form: [
|
||||||
|
"Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteJobs: InviteJob[] = [];
|
const inviteJobs: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -239,19 +240,12 @@ export async function addFamilyAction(
|
|||||||
role: UserRole.ELTERN,
|
role: UserRole.ELTERN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const invite = createInviteToken();
|
const invite = await queueFamilyInvitation(
|
||||||
inviteJobs.push({
|
tx,
|
||||||
to: parent.email,
|
createdParent.id,
|
||||||
parentName: `${parent.firstName} ${parent.lastName}`,
|
session.user.id,
|
||||||
inviteUrl: invite.inviteUrl,
|
);
|
||||||
});
|
inviteJobs.push(invite.id);
|
||||||
await tx.verificationToken.create({
|
|
||||||
data: {
|
|
||||||
identifier: createdParent.id,
|
|
||||||
token: hashToken(invite.token),
|
|
||||||
expires: invite.expires,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.child.createMany({
|
await tx.child.createMany({
|
||||||
@@ -274,26 +268,25 @@ export async function addFamilyAction(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw err;
|
console.error("Familie konnte nicht angelegt werden:", err);
|
||||||
}
|
|
||||||
|
|
||||||
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
|
||||||
if (emailError) {
|
|
||||||
return {
|
return {
|
||||||
errors: {
|
errors: {
|
||||||
_form: [
|
_form: [
|
||||||
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
|
"Familie konnte nicht angelegt werden. Bitte erneut versuchen.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warning = await sendInviteJobs(inviteJobs);
|
||||||
revalidatePath("/dashboard/families");
|
revalidatePath("/dashboard/families");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true, warning };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateFamilyAction(rawPayload: unknown) {
|
export async function updateFamilyAction(
|
||||||
|
rawPayload: unknown,
|
||||||
|
): Promise<{ success?: boolean; error?: string; warning?: string }> {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId;
|
||||||
|
|
||||||
@@ -316,25 +309,25 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
|||||||
removedChildIds,
|
removedChildIds,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
|
const parentEmails = [...parents, ...newParents].map(
|
||||||
|
(parent) => parent.email,
|
||||||
|
);
|
||||||
if (new Set(parentEmails).size !== parentEmails.length) {
|
if (new Set(parentEmails).size !== parentEmails.length) {
|
||||||
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
|
return {
|
||||||
|
error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const removedChildIdSet = new Set(removedChildIds);
|
const removedChildIdSet = new Set(removedChildIds);
|
||||||
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
|
const activeChildren = children.filter(
|
||||||
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
|
(child) => !removedChildIdSet.has(child.id),
|
||||||
|
);
|
||||||
|
if (activeChildren.length + newChildren.length > 20)
|
||||||
|
return { error: "Maximal 20 Kinder pro Familie." };
|
||||||
if (activeChildren.length + newChildren.length === 0) {
|
if (activeChildren.length + newChildren.length === 0) {
|
||||||
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
return { error: "Eine Familie benötigt mindestens ein Kind." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newParents.length > 0) {
|
|
||||||
const mailConfigError = getAppEmailConfigError();
|
|
||||||
if (mailConfigError) {
|
|
||||||
return { error: mailConfigError };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const kita = await prisma.kita.findUnique({
|
const kita = await prisma.kita.findUnique({
|
||||||
where: { id: kitaId },
|
where: { id: kitaId },
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
@@ -344,7 +337,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
|||||||
return { error: "Kita wurde nicht gefunden." };
|
return { error: "Kita wurde nicht gefunden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteJobs: InviteJob[] = [];
|
const inviteJobs: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingFamily = await prisma.family.findFirst({
|
const existingFamily = await prisma.family.findFirst({
|
||||||
@@ -360,18 +353,31 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
|||||||
return { error: "Familie wurde nicht gefunden." };
|
return { error: "Familie wurde nicht gefunden." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
|
const needsEmail =
|
||||||
|
newParents.length > 0 ||
|
||||||
|
parents.some(
|
||||||
|
(parent) =>
|
||||||
|
existingFamily.users.find((user) => user.id === parent.id)?.email !==
|
||||||
|
parent.email,
|
||||||
|
);
|
||||||
if (needsEmail) {
|
if (needsEmail) {
|
||||||
const configError = getAppEmailConfigError();
|
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
|
||||||
if (configError) return { error: configError };
|
return {
|
||||||
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
|
error: "Zu viele Einladungen. Bitte versuche es später erneut.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
|
const existingParentIds = new Set(
|
||||||
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
|
existingFamily.users.map((user) => user.id),
|
||||||
|
);
|
||||||
|
const existingChildIds = new Set(
|
||||||
|
existingFamily.children.map((child) => child.id),
|
||||||
|
);
|
||||||
|
|
||||||
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
|
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
|
||||||
return { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
|
return {
|
||||||
|
error: "Mindestens ein Elternteil gehört nicht zu dieser Familie.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!children.every((child) => existingChildIds.has(child.id))) {
|
if (!children.every((child) => existingChildIds.has(child.id))) {
|
||||||
@@ -386,26 +392,59 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
|||||||
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
|
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await lockKitaAccounts(tx, kitaId);
|
||||||
|
const changedEmails = parents
|
||||||
|
.filter(
|
||||||
|
(parent) =>
|
||||||
|
existingFamily.users.find((user) => user.id === parent.id)
|
||||||
|
?.email !== parent.email,
|
||||||
|
)
|
||||||
|
.map((parent) => parent.id);
|
||||||
|
await assertKitaAdminRemains(tx, kitaId, changedEmails);
|
||||||
|
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
|
||||||
|
const removed = await tx.child.count({
|
||||||
|
where: { kitaId, familyId, id: { in: removedChildIds } },
|
||||||
|
});
|
||||||
|
if (removed !== removedChildIds.length)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Mindestens ein Kind gehört nicht zu dieser Familie.",
|
||||||
|
);
|
||||||
await tx.family.update({
|
await tx.family.update({
|
||||||
where: { id: familyId },
|
where: { id: familyId },
|
||||||
data: { name: familyName },
|
data: { name: familyName },
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const parent of parents) {
|
for (const parent of parents) {
|
||||||
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
|
const current = await tx.user.findFirst({
|
||||||
|
where: { id: parent.id, kitaId, familyId },
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
if (!current) throw new Error("Parent assignment changed");
|
if (!current) throw new Error("Parent assignment changed");
|
||||||
const emailChanged = current.email !== parent.email;
|
const emailChanged = current.email !== parent.email;
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: parent.id, kitaId, familyId },
|
where: { id: parent.id, kitaId, familyId },
|
||||||
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
|
data: {
|
||||||
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
|
firstName: parent.firstName,
|
||||||
|
lastName: parent.lastName,
|
||||||
|
email: parent.email,
|
||||||
|
...(emailChanged
|
||||||
|
? {
|
||||||
|
passwordHash: "",
|
||||||
|
emailVerifiedAt: null,
|
||||||
|
sessionVersion: { increment: 1 },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (emailChanged) {
|
if (emailChanged) {
|
||||||
const invite = createInviteToken();
|
const invite = await queueFamilyInvitation(
|
||||||
await tx.verificationToken.deleteMany({ where: { identifier: parent.id } });
|
tx,
|
||||||
await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } });
|
parent.id,
|
||||||
inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl });
|
session.user.id,
|
||||||
|
);
|
||||||
|
inviteJobs.push(invite.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,19 +460,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
|||||||
role: UserRole.ELTERN,
|
role: UserRole.ELTERN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const invite = createInviteToken();
|
const invite = await queueFamilyInvitation(
|
||||||
inviteJobs.push({
|
tx,
|
||||||
to: parent.email,
|
createdParent.id,
|
||||||
parentName: `${parent.firstName} ${parent.lastName}`,
|
session.user.id,
|
||||||
inviteUrl: invite.inviteUrl,
|
);
|
||||||
});
|
inviteJobs.push(invite.id);
|
||||||
await tx.verificationToken.create({
|
|
||||||
data: {
|
|
||||||
identifier: createdParent.id,
|
|
||||||
token: hashToken(invite.token),
|
|
||||||
expires: invite.expires,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of activeChildren) {
|
for (const child of activeChildren) {
|
||||||
@@ -460,7 +492,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -469,45 +503,76 @@ export async function updateFamilyAction(rawPayload: unknown) {
|
|||||||
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(error instanceof UserFacingError))
|
||||||
console.error("Fehler beim Aktualisieren der Familie:", error);
|
console.error("Fehler beim Aktualisieren der Familie:", error);
|
||||||
return { error: "Familie konnte nicht aktualisiert werden." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailError = await sendInviteJobs(kita.name, inviteJobs);
|
|
||||||
if (emailError) {
|
|
||||||
return {
|
return {
|
||||||
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Familie konnte nicht aktualisiert werden.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warning = await sendInviteJobs(inviteJobs);
|
||||||
revalidatePath("/dashboard/families");
|
revalidatePath("/dashboard/families");
|
||||||
revalidatePath("/dashboard/profil");
|
revalidatePath("/dashboard/profil");
|
||||||
revalidatePath("/dashboard/notdienst");
|
revalidatePath("/dashboard/notdienst");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true, warning };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendFamilyInvitationAction(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ success?: boolean; error?: string; warning?: string }> {
|
||||||
|
const session = await requireRole([UserRole.ADMIN]);
|
||||||
|
if (
|
||||||
|
!session.user.kitaId ||
|
||||||
|
typeof userId !== "string" ||
|
||||||
|
!userId ||
|
||||||
|
userId.length > 64
|
||||||
|
)
|
||||||
|
return { error: "Ungültiges Elternkonto." };
|
||||||
|
if (
|
||||||
|
!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600))
|
||||||
|
.allowed
|
||||||
|
)
|
||||||
|
return { error: "Bitte versuche es später erneut." };
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
|
return { error: "Bitte versuche es später erneut." };
|
||||||
|
const recipient = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: { not: null },
|
||||||
|
emailVerifiedAt: null,
|
||||||
|
passwordHash: "",
|
||||||
|
disabledAt: null,
|
||||||
|
},
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
|
if (!recipient) return { error: "Keine offene Einladung gefunden." };
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await resendFamilyInvitation(
|
||||||
|
session.user.kitaId,
|
||||||
|
userId,
|
||||||
|
session.user.id,
|
||||||
|
recipient.email,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
result = {
|
||||||
|
error:
|
||||||
|
"Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
revalidatePath("/dashboard/families");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resendParentInvite(userId: string) {
|
export async function resendParentInvite(userId: string) {
|
||||||
const session = await requireRole([UserRole.ADMIN]);
|
return resendFamilyInvitationAction(userId);
|
||||||
if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." };
|
|
||||||
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
|
||||||
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
|
||||||
const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null },
|
|
||||||
select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } });
|
|
||||||
if (!user?.kita) return { error: "Keine offene Einladung gefunden." };
|
|
||||||
const configError = getAppEmailConfigError(); if (configError) return { error: configError };
|
|
||||||
const invite = createInviteToken();
|
|
||||||
const renewed = await prisma.$transaction(async tx => {
|
|
||||||
// Lock and re-check the account before issuing a link for the selected mailbox.
|
|
||||||
// A concurrent address change/activation must invalidate this resend.
|
|
||||||
const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId,
|
|
||||||
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } });
|
|
||||||
if (eligible.count !== 1) return false;
|
|
||||||
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
|
|
||||||
await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } });
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
|
|
||||||
const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]);
|
|
||||||
return error ? { error } : { success: true };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useState } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -26,28 +27,24 @@ const initialState: AddFamilyState = {};
|
|||||||
// • Dialog-Open/Close-State
|
// • Dialog-Open/Close-State
|
||||||
// • Dynamische Kinderliste (min. 1, max. 10)
|
// • Dynamische Kinderliste (min. 1, max. 10)
|
||||||
// • Ein oder zwei Elternteile pro Haushalt
|
// • Ein oder zwei Elternteile pro Haushalt
|
||||||
// • useActionState → Server-Action-Fehler anzeigen
|
// • Eingaben bei Validierungsfehlern behalten
|
||||||
// • Bei Erfolg (state.success) Dialog automatisch schließen
|
// • Nach erfolgreichem Speichern schließen, auch bei einer Versandwarnung
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
export function AddFamilyDialog() {
|
export function AddFamilyDialog() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [childCount, setChildCount] = useState(1);
|
const [childIds, setChildIds] = useState(["initial"]);
|
||||||
const [state, formAction, pending] = useActionState(
|
const childCount = childIds.length;
|
||||||
addFamilyAction,
|
const [state, setState] = useState<AddFamilyState>(initialState);
|
||||||
initialState,
|
const [pending, startTransition] = useTransition();
|
||||||
);
|
|
||||||
|
|
||||||
// Dialog schließen, wenn die Action erfolgreich war
|
|
||||||
if (state.success && open) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
if (pending) return;
|
||||||
|
if (nextOpen) {
|
||||||
|
setState(initialState);
|
||||||
|
setChildIds([crypto.randomUUID()]);
|
||||||
|
}
|
||||||
setOpen(nextOpen);
|
setOpen(nextOpen);
|
||||||
// State beim Schließen zurücksetzen würde useActionState erfordern —
|
|
||||||
// stattdessen schließen wir einfach und der State bleibt bis zur nächsten
|
|
||||||
// Aktion erhalten (unkritisch).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,12 +60,28 @@ export function AddFamilyDialog() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Familie hinzufügen</DialogTitle>
|
<DialogTitle>Familie hinzufügen</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Lege einen Haushalt mit Elternteilen und Kindern an. Die
|
Lege einen Haushalt mit Elternteilen und Kindern an. Die Elternteile
|
||||||
Elternteile erhalten Einladungslinks per E-Mail.
|
erhalten Einladungslinks per E-Mail.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form action={formAction} className="space-y-6">
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await addFamilyAction(initialState, data);
|
||||||
|
setState(result);
|
||||||
|
if (result.success) {
|
||||||
|
if (result.warning) toast.warning(result.warning);
|
||||||
|
else toast.success("Familie angelegt. Einladungen versendet.");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<fieldset disabled={pending} className="space-y-6">
|
||||||
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
|
{/* Anzahl Kinder als verstecktes Feld für die Server Action */}
|
||||||
<input type="hidden" name="childCount" value={childCount} />
|
<input type="hidden" name="childCount" value={childCount} />
|
||||||
|
|
||||||
@@ -164,7 +177,9 @@ export function AddFamilyDialog() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setChildCount((c) => c + 1)}
|
onClick={() =>
|
||||||
|
setChildIds((ids) => [...ids, crypto.randomUUID()])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Kind hinzufügen
|
Kind hinzufügen
|
||||||
@@ -172,18 +187,18 @@ export function AddFamilyDialog() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Array.from({ length: childCount }).map((_, i) => (
|
{childIds.map((childId, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={childId}
|
||||||
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
|
className="relative rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
|
||||||
>
|
>
|
||||||
{childCount > 1 && (
|
{childCount > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Kind entfernen — childCount reduzieren reicht, da die
|
setChildIds((ids) =>
|
||||||
// Felder nach Index benannt sind und der letzte entfernt wird.
|
ids.filter((id) => id !== childId),
|
||||||
setChildCount((c) => c - 1);
|
);
|
||||||
}}
|
}}
|
||||||
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)]"
|
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`}
|
aria-label={`Kind ${i + 1} entfernen`}
|
||||||
@@ -222,6 +237,7 @@ export function AddFamilyDialog() {
|
|||||||
{state.errors._form[0]}
|
{state.errors._form[0]}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
import { createDuty, assignDuty, removeDutyAssignment, deleteDuty } from "./duty-actions";
|
import {
|
||||||
|
createDuty,
|
||||||
|
assignDuty,
|
||||||
|
removeDutyAssignment,
|
||||||
|
deleteDuty,
|
||||||
|
} from "./duty-actions";
|
||||||
|
|
||||||
type DutyWithAssignments = {
|
type DutyWithAssignments = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -100,17 +105,25 @@ export function DutyManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const unassignedDuties = allDuties.filter(
|
const unassignedDuties = allDuties.filter(
|
||||||
(d) => !userAssignments.some((a) => a.dutyId === d.id)
|
(d) => !userAssignments.some((a) => a.dutyId === d.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
aria-label={`Ämter für ${user.firstName} ${user.lastName}`}
|
||||||
|
>
|
||||||
<Shield className="h-3.5 w-3.5" />
|
<Shield className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">Ämter</span>
|
<span>Ämter: {user.firstName}</span>
|
||||||
{userAssignments.length > 0 && (
|
{userAssignments.length > 0 && (
|
||||||
<Badge variant="secondary" className="ml-1 h-4 px-1 py-0 tabular-nums">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-1 h-4 px-1 py-0 tabular-nums"
|
||||||
|
>
|
||||||
{userAssignments.length}
|
{userAssignments.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -137,7 +150,11 @@ export function DutyManager({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{userAssignments.map((assignment) => (
|
{userAssignments.map((assignment) => (
|
||||||
<Badge key={assignment.id} variant="default" className="gap-1 pr-1">
|
<Badge
|
||||||
|
key={assignment.id}
|
||||||
|
variant="default"
|
||||||
|
className="gap-1 pr-1"
|
||||||
|
>
|
||||||
{assignment.duty.name}
|
{assignment.duty.name}
|
||||||
<button
|
<button
|
||||||
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)]"
|
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)]"
|
||||||
@@ -199,7 +216,12 @@ export function DutyManager({
|
|||||||
Neues Amt anlegen
|
Neues Amt anlegen
|
||||||
</h4>
|
</h4>
|
||||||
<form action={handleCreateDuty} className="flex gap-2">
|
<form action={handleCreateDuty} className="flex gap-2">
|
||||||
<Input name="name" placeholder="z.B. Wäschedienst" className="h-8" required />
|
<Input
|
||||||
|
name="name"
|
||||||
|
placeholder="z.B. Wäschedienst"
|
||||||
|
className="h-8"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Button size="sm" className="h-8" disabled={isPending}>
|
<Button size="sm" className="h-8" disabled={isPending}>
|
||||||
Erstellen
|
Erstellen
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
type ChangeEventHandler,
|
||||||
|
} from "react";
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -17,12 +22,15 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { updateFamilyAction } from "./actions";
|
import { updateFamilyAction } from "./actions";
|
||||||
|
import { InvitationStatus } from "./invitation-status";
|
||||||
|
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
|
||||||
|
|
||||||
type EditableParent = {
|
type EditableParent = {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
invitation?: FamilyInvitationView;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditableChild = {
|
type EditableChild = {
|
||||||
@@ -47,6 +55,7 @@ type EditableFamily = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const initialParents = useMemo(
|
const initialParents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -154,13 +163,22 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
|
if (result.warning) toast.warning(result.warning);
|
||||||
|
else toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden.");
|
||||||
|
setOpen(false);
|
||||||
setRemovedChildIds([]);
|
setRemovedChildIds([]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={(open) => !open && resetState()}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (isPending) return;
|
||||||
|
resetState();
|
||||||
|
setOpen(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
<Button variant="ghost" size="sm" className="h-8 gap-1">
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
@@ -168,7 +186,12 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
<form action={handleSubmit}>
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSubmit(new FormData(event.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Familie bearbeiten</DialogTitle>
|
<DialogTitle>Familie bearbeiten</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -176,7 +199,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-6 py-4">
|
<fieldset disabled={isPending} className="grid gap-6 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
|
<Label htmlFor={`familyName-${family.id}`}>Familienname</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -251,8 +274,38 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
|||||||
label="E-Mail-Adresse"
|
label="E-Mail-Adresse"
|
||||||
type="email"
|
type="email"
|
||||||
defaultValue={parent.email}
|
defaultValue={parent.email}
|
||||||
|
onChange={(event) => {
|
||||||
|
const email = event.target.value;
|
||||||
|
setParents((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === parent.id ? { ...item, email } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{parent.kind === "existing" && parent.invitation && (
|
||||||
|
<InvitationStatus
|
||||||
|
userId={parent.id}
|
||||||
|
name={`${parent.firstName} ${parent.lastName}`}
|
||||||
|
invitation={parent.invitation}
|
||||||
|
blocked={
|
||||||
|
parent.email !==
|
||||||
|
family.parents.find((item) => item.id === parent.id)
|
||||||
|
?.email
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{parent.kind === "existing" &&
|
||||||
|
parent.invitation?.status !== "active" &&
|
||||||
|
parent.email !==
|
||||||
|
family.parents.find((item) => item.id === parent.id)
|
||||||
|
?.email && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--ink-soft)]">
|
||||||
|
Beim Speichern wird eine neue Einladung an die geänderte
|
||||||
|
Adresse versendet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -319,7 +372,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
@@ -338,12 +391,14 @@ function Field({
|
|||||||
label,
|
label,
|
||||||
type = "text",
|
type = "text",
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -353,6 +408,7 @@ function Field({
|
|||||||
name={name}
|
name={name}
|
||||||
type={type}
|
type={type}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
|
onChange={onChange}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { Mail } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { FamilyInvitationView } from "@/lib/family-invitation-view";
|
||||||
|
import { resendFamilyInvitationAction } from "./actions";
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
active: "Konto aktiviert",
|
||||||
|
open: "Aktivierung offen",
|
||||||
|
sending: "Versand läuft",
|
||||||
|
sent: "Einladung versendet",
|
||||||
|
failed: "Versand fehlgeschlagen",
|
||||||
|
expired: "Einladung abgelaufen",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvitationStatus({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
invitation,
|
||||||
|
blocked = false,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
invitation: FamilyInvitationView;
|
||||||
|
blocked?: boolean;
|
||||||
|
}) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Badge variant={invitation.status === "active" ? "success" : "warning"}>
|
||||||
|
{labels[invitation.status]}
|
||||||
|
</Badge>
|
||||||
|
{invitation.status !== "active" && (
|
||||||
|
<>
|
||||||
|
{invitation.expiresAt && (
|
||||||
|
<span className="text-[var(--ink-muted)]">
|
||||||
|
{invitation.status === "expired"
|
||||||
|
? "Gültig gewesen bis"
|
||||||
|
: "Link gültig bis"}{" "}
|
||||||
|
{new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
}).format(new Date(invitation.expiresAt))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={blocked || pending || invitation.status === "sending"}
|
||||||
|
aria-label={`Einladung erneut senden an ${name}`}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await resendFamilyInvitationAction(userId);
|
||||||
|
if (result.error) toast.error(result.error);
|
||||||
|
else if (result.warning) toast.warning(result.warning);
|
||||||
|
else toast.success("Einladung versendet.");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
{pending ? "Wird versendet…" : "Einladung erneut senden"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { UserRole } from "@prisma/client";
|
|||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { familyInvitationView } from "@/lib/family-invitation-view";
|
||||||
import { FamiliesListClient } from "./_components/families-list-client";
|
import { FamiliesListClient } from "./_components/families-list-client";
|
||||||
|
|
||||||
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
|
export const metadata = { title: "Familienverwaltung · Kita-Planer" };
|
||||||
@@ -31,7 +32,18 @@ export default async function FamiliesPage() {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
role: true,
|
||||||
|
staffProfile: { select: { id: true } },
|
||||||
emailVerifiedAt: true,
|
emailVerifiedAt: true,
|
||||||
|
familyInvitation: {
|
||||||
|
select: {
|
||||||
|
expiresAt: true,
|
||||||
|
sentAt: true,
|
||||||
|
failed: true,
|
||||||
|
leaseUntil: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
dutyAssignments: {
|
dutyAssignments: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -73,7 +85,17 @@ export default async function FamiliesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
<FamiliesListClient
|
<FamiliesListClient
|
||||||
initialFamilies={families}
|
currentUserId={session.user.id}
|
||||||
|
initialFamilies={families.map((family) => ({
|
||||||
|
...family,
|
||||||
|
users: family.users.map(({ familyInvitation, ...parent }) => ({
|
||||||
|
...parent,
|
||||||
|
invitation: familyInvitationView(
|
||||||
|
parent.emailVerifiedAt,
|
||||||
|
familyInvitation,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}))}
|
||||||
allDuties={allDuties}
|
allDuties={allDuties}
|
||||||
canManageDuties={canManageDuties}
|
canManageDuties={canManageDuties}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useTransition } from "react";
|
import { useState, useMemo, useTransition } from "react";
|
||||||
import {
|
import { TerminType, TerminStatus } from "@prisma/client";
|
||||||
TerminType,
|
|
||||||
TerminStatus,
|
|
||||||
} from "@prisma/client";
|
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
@@ -30,7 +27,7 @@ import {
|
|||||||
WashingMachine,
|
WashingMachine,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
|
import { RsvpForm } from "@/components/rsvp-form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -42,7 +39,6 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -64,12 +60,32 @@ import {
|
|||||||
const categories = [
|
const categories = [
|
||||||
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
|
{ label: "Schließtag", type: TerminType.SCHLIESSTAG, color: "var(--danger)" },
|
||||||
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
|
{ label: "Teamtag", type: TerminType.TEAMTAG, color: "var(--danger)" },
|
||||||
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
|
{
|
||||||
|
label: "Elternabend",
|
||||||
|
type: TerminType.ELTERNABEND,
|
||||||
|
color: "oklch(0.50 0.10 280)",
|
||||||
|
},
|
||||||
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
|
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
|
||||||
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
|
{
|
||||||
{ label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
|
label: "Geburtstag",
|
||||||
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
|
type: TerminType.GEBURTSTAG_INTERN,
|
||||||
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
|
color: "oklch(0.50 0.10 320)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mein Elterndienst",
|
||||||
|
type: "mein-elterndienst",
|
||||||
|
color: "oklch(0.60 0.16 160)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Elterndienst",
|
||||||
|
type: "elterndienst",
|
||||||
|
color: "oklch(0.75 0.08 160)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mein Notdienst",
|
||||||
|
type: "mein-notdienst",
|
||||||
|
color: "oklch(0.60 0.18 45)",
|
||||||
|
},
|
||||||
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
|
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
|
||||||
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
||||||
];
|
];
|
||||||
@@ -102,7 +118,9 @@ export function CalendarOverviewClient({
|
|||||||
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
|
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
||||||
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
|
const [filterScope, setFilterScope] = useState<"own" | "all">(
|
||||||
|
isAdmin ? "all" : "own",
|
||||||
|
);
|
||||||
|
|
||||||
// Month navigation state
|
// Month navigation state
|
||||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||||
@@ -152,7 +170,8 @@ export function CalendarOverviewClient({
|
|||||||
item.kind === "notdienst" && item.childName
|
item.kind === "notdienst" && item.childName
|
||||||
? item.childName.toLowerCase().includes(query)
|
? item.childName.toLowerCase().includes(query)
|
||||||
: false;
|
: false;
|
||||||
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
|
if (!titleMatch && !descMatch && !familyMatch && !childMatch)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Category filter
|
// 2. Category filter
|
||||||
@@ -178,9 +197,21 @@ export function CalendarOverviewClient({
|
|||||||
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
||||||
const eventStart = new Date(item.startDate);
|
const eventStart = new Date(item.startDate);
|
||||||
const eventEnd = new Date(item.endDate);
|
const eventEnd = new Date(item.endDate);
|
||||||
const dayTime = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
const dayTime = new Date(
|
||||||
const startTime = new Date(eventStart.getFullYear(), eventStart.getMonth(), eventStart.getDate()).getTime();
|
day.getFullYear(),
|
||||||
const endTime = new Date(eventEnd.getFullYear(), eventEnd.getMonth(), eventEnd.getDate()).getTime();
|
day.getMonth(),
|
||||||
|
day.getDate(),
|
||||||
|
).getTime();
|
||||||
|
const startTime = new Date(
|
||||||
|
eventStart.getFullYear(),
|
||||||
|
eventStart.getMonth(),
|
||||||
|
eventStart.getDate(),
|
||||||
|
).getTime();
|
||||||
|
const endTime = new Date(
|
||||||
|
eventEnd.getFullYear(),
|
||||||
|
eventEnd.getMonth(),
|
||||||
|
eventEnd.getDate(),
|
||||||
|
).getTime();
|
||||||
return dayTime >= startTime && dayTime <= endTime;
|
return dayTime >= startTime && dayTime <= endTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,7 +238,7 @@ export function CalendarOverviewClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{!isAdmin && pendingSwapRequests.length > 0 && (
|
{pendingSwapRequests.length > 0 && (
|
||||||
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
|
<NotdienstSwapRequestsPanel requests={pendingSwapRequests} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -330,7 +361,7 @@ export function CalendarOverviewClient({
|
|||||||
<AgendaView
|
<AgendaView
|
||||||
events={filteredEvents}
|
events={filteredEvents}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
userId={userId}
|
onOpenDetails={(item) => setSelectedDay(new Date(item.startDate))}
|
||||||
onDeleteTermin={(id, title) => {
|
onDeleteTermin={(id, title) => {
|
||||||
setDeleteEventId(id);
|
setDeleteEventId(id);
|
||||||
setDeleteEventTitle(title);
|
setDeleteEventTitle(title);
|
||||||
@@ -339,11 +370,15 @@ export function CalendarOverviewClient({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Day details Dialog for Month View */}
|
{/* Day details Dialog for Month View */}
|
||||||
<Dialog open={!!selectedDay} onOpenChange={(open) => !open && setSelectedDay(null)}>
|
<Dialog
|
||||||
|
open={!!selectedDay}
|
||||||
|
onOpenChange={(open) => !open && setSelectedDay(null)}
|
||||||
|
>
|
||||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">
|
<DialogTitle className="text-xl">
|
||||||
{selectedDay && format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
{selectedDay &&
|
||||||
|
format(selectedDay, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
|
Übersicht aller Zuteilungen, Feste und Einträge an diesem Tag.
|
||||||
@@ -372,7 +407,14 @@ export function CalendarOverviewClient({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
||||||
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
|
<WashingMachine
|
||||||
|
className="h-5 w-5 [stroke-width:1.7]"
|
||||||
|
style={{
|
||||||
|
color: item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -410,7 +452,11 @@ export function CalendarOverviewClient({
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<a href="/dashboard/admin/dienste">
|
<a href="/dashboard/admin/dienste">
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
>
|
||||||
Im Planer bearbeiten
|
Im Planer bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -462,20 +508,30 @@ export function CalendarOverviewClient({
|
|||||||
</p>
|
</p>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<a href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}>
|
<a
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
href={`/dashboard/notdienst/plan?year=${item.planYear}&month=${item.planMonth}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs px-2.5"
|
||||||
|
>
|
||||||
Im Planer bearbeiten
|
Im Planer bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isAdmin && item.canRequestSwap && (
|
{item.canRequestSwap && (
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<NotdienstSwapDialog
|
<NotdienstSwapDialog
|
||||||
target={item}
|
target={item}
|
||||||
options={notdienstSwapOptions}
|
options={notdienstSwapOptions}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2.5 text-xs">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2.5 text-xs"
|
||||||
|
>
|
||||||
Tausch anfragen
|
Tausch anfragen
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -500,7 +556,8 @@ export function CalendarOverviewClient({
|
|||||||
style={{
|
style={{
|
||||||
color: getCategoryColor(item.type),
|
color: getCategoryColor(item.type),
|
||||||
borderColor: getCategoryColor(item.type) + "44",
|
borderColor: getCategoryColor(item.type) + "44",
|
||||||
backgroundColor: getCategoryColor(item.type) + "11",
|
backgroundColor:
|
||||||
|
getCategoryColor(item.type) + "11",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getTypeLabel(item.type)}
|
{getTypeLabel(item.type)}
|
||||||
@@ -523,10 +580,17 @@ export function CalendarOverviewClient({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
{item.rsvpEvent ? (
|
||||||
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
|
<section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
||||||
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
|
<h5 className="mb-3 font-medium">
|
||||||
</section> : null}
|
Eure Rückmeldung
|
||||||
|
</h5>
|
||||||
|
<RsvpForm
|
||||||
|
key={JSON.stringify(item.rsvpEvent)}
|
||||||
|
event={item.rsvpEvent}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{item.participantInfo && item.showParticipantInfo && (
|
{item.participantInfo && item.showParticipantInfo && (
|
||||||
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
|
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
|
||||||
@@ -534,7 +598,9 @@ export function CalendarOverviewClient({
|
|||||||
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
|
<AlertTriangle className="h-3.5 w-3.5 text-[var(--warn)]" />
|
||||||
Hinweis für Teilnehmer
|
Hinweis für Teilnehmer
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap">{item.participantInfo}</p>
|
<p className="whitespace-pre-wrap">
|
||||||
|
{item.participantInfo}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -582,13 +648,17 @@ export function CalendarOverviewClient({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog open={!!deleteEventId} onOpenChange={(open) => !open && setDeleteEventId(null)}>
|
<Dialog
|
||||||
|
open={!!deleteEventId}
|
||||||
|
onOpenChange={(open) => !open && setDeleteEventId(null)}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Möchtest du den Termin “{deleteEventTitle}” wirklich löschen? Alle
|
Möchtest du den Termin “{deleteEventTitle}” wirklich
|
||||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||||
|
gehen verloren.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -642,7 +712,9 @@ function NotdienstSwapRequestRow({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const runAction = (
|
const runAction = (
|
||||||
action: (requestId: string) => Promise<{ success?: boolean; error?: string }>,
|
action: (
|
||||||
|
requestId: string,
|
||||||
|
) => Promise<{ success?: boolean; error?: string }>,
|
||||||
successMessage: string,
|
successMessage: string,
|
||||||
) => {
|
) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -659,13 +731,15 @@ function NotdienstSwapRequestRow({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||||
{request.direction === "incoming" ? "Anfrage an dich" : "Deine Anfrage"}
|
{request.direction === "incoming"
|
||||||
|
? "Anfrage an dich"
|
||||||
|
: "Deine Anfrage"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
|
<p className="mt-1 text-sm leading-5 text-[var(--ink-soft)]">
|
||||||
{format(request.targetDate, "dd.MM.yyyy", { locale: de })}{" "}
|
{format(request.targetDate, "dd.MM.yyyy", { locale: de })} (
|
||||||
({request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
{request.targetFamilyName}, {request.targetChildName}) gegen{" "}
|
||||||
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })}{" "}
|
{format(request.offeredDate, "dd.MM.yyyy", { locale: de })} (
|
||||||
({request.offeredFamilyName}, {request.offeredChildName})
|
{request.offeredFamilyName}, {request.offeredChildName})
|
||||||
</p>
|
</p>
|
||||||
{request.message && (
|
{request.message && (
|
||||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||||
@@ -679,7 +753,10 @@ function NotdienstSwapRequestRow({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runAction(acceptNotdienstSwapRequest, "Tausch wurde angenommen.")
|
runAction(
|
||||||
|
acceptNotdienstSwapRequest,
|
||||||
|
"Tausch wurde angenommen.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
@@ -689,7 +766,10 @@ function NotdienstSwapRequestRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runAction(declineNotdienstSwapRequest, "Tausch wurde abgelehnt.")
|
runAction(
|
||||||
|
declineNotdienstSwapRequest,
|
||||||
|
"Tausch wurde abgelehnt.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
@@ -701,7 +781,10 @@ function NotdienstSwapRequestRow({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runAction(cancelNotdienstSwapRequest, "Tauschanfrage wurde zurückgezogen.")
|
runAction(
|
||||||
|
cancelNotdienstSwapRequest,
|
||||||
|
"Tauschanfrage wurde zurückgezogen.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
@@ -791,7 +874,9 @@ function MonthView({
|
|||||||
onClick={() => onDayClick(day)}
|
onClick={() => onDayClick(day)}
|
||||||
className={[
|
className={[
|
||||||
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
|
"min-h-[105px] bg-[var(--surface)] p-2 transition-colors hover:bg-[var(--surface-2)] cursor-pointer flex flex-col justify-between",
|
||||||
!isCurrentMonth ? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60" : "text-[var(--ink)]",
|
!isCurrentMonth
|
||||||
|
? "bg-[var(--surface-3)]/30 text-[var(--ink-muted)] opacity-60"
|
||||||
|
: "text-[var(--ink)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{/* Day number cell top */}
|
{/* Day number cell top */}
|
||||||
@@ -856,25 +941,33 @@ function MonthView({
|
|||||||
function AgendaView({
|
function AgendaView({
|
||||||
events,
|
events,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
userId,
|
|
||||||
onDeleteTermin,
|
onDeleteTermin,
|
||||||
|
onOpenDetails,
|
||||||
}: {
|
}: {
|
||||||
events: CalendarListItemDto[];
|
events: CalendarListItemDto[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userId: string;
|
|
||||||
onDeleteTermin: (id: string, title: string) => void;
|
onDeleteTermin: (id: string, title: string) => void;
|
||||||
|
onOpenDetails: (item: CalendarListItemDto) => void;
|
||||||
}) {
|
}) {
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const groups = new Map<string, { date: Date; items: CalendarListItemDto[] }>();
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{ date: Date; items: CalendarListItemDto[] }
|
||||||
|
>();
|
||||||
|
|
||||||
events.forEach((item) => {
|
events.forEach((item) => {
|
||||||
const key = format(new Date(item.startDate), "yyyy-MM-dd");
|
const key = format(new Date(item.startDate), "yyyy-MM-dd");
|
||||||
const group = groups.get(key) ?? { date: new Date(item.startDate), items: [] };
|
const group = groups.get(key) ?? {
|
||||||
|
date: new Date(item.startDate),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
group.items.push(item);
|
group.items.push(item);
|
||||||
groups.set(key, group);
|
groups.set(key, group);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(groups.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
|
return Array.from(groups.values()).sort(
|
||||||
|
(a, b) => a.date.getTime() - b.date.getTime(),
|
||||||
|
);
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
@@ -896,7 +989,10 @@ function AgendaView({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
|
<div className="space-y-6 pl-4 border-l-2 border-[var(--hairline)]">
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.date.toString()} className="relative grid grid-cols-[110px_1fr] gap-4 items-start">
|
<div
|
||||||
|
key={group.date.toString()}
|
||||||
|
className="relative grid grid-cols-1 sm:grid-cols-[110px_minmax(0,1fr)] gap-4 items-start"
|
||||||
|
>
|
||||||
{/* Timeline marker */}
|
{/* Timeline marker */}
|
||||||
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
|
<div className="absolute -left-[21px] top-1.5 h-3.5 w-3.5 rounded-full border-2 border-[var(--accent-deep)] bg-[var(--surface)]" />
|
||||||
|
|
||||||
@@ -929,7 +1025,7 @@ function AgendaView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="group relative flex items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
className="group relative flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -965,12 +1061,18 @@ function AgendaView({
|
|||||||
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]"
|
||||||
|
>
|
||||||
Elterndienst
|
Elterndienst
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
|
{item.kind === "termin" &&
|
||||||
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
|
item.status === TerminStatus.PENDING && (
|
||||||
|
<Badge variant="warning" className="text-[10px]">
|
||||||
|
Ausstehend
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -989,7 +1091,9 @@ function AgendaView({
|
|||||||
new Date(item.endDate),
|
new Date(item.endDate),
|
||||||
"dd.MM.yyyy",
|
"dd.MM.yyyy",
|
||||||
)}`}
|
)}`}
|
||||||
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
|
{item.kind === "termin" && (
|
||||||
|
<MapPin className="ml-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
{item.kind === "termin"
|
{item.kind === "termin"
|
||||||
? "Kita"
|
? "Kita"
|
||||||
: item.kind === "notdienst"
|
: item.kind === "notdienst"
|
||||||
@@ -1005,7 +1109,14 @@ function AgendaView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
aria-label={`Details zu ${item.title}`}
|
||||||
|
onClick={() => onOpenDetails(item)}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Actions for admins */}
|
{/* Actions for admins */}
|
||||||
{isAdmin && item.kind === "termin" && (
|
{isAdmin && item.kind === "termin" && (
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Trash2, Plus, Utensils } from "lucide-react";
|
import { Trash2, Plus, Utensils, Pencil } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { addMitbringsel, deleteMitbringsel } from "../actions";
|
import {
|
||||||
|
addMitbringsel,
|
||||||
|
deleteMitbringsel,
|
||||||
|
updateMitbringsel,
|
||||||
|
} from "../actions";
|
||||||
|
|
||||||
type MitbringselItemDto = {
|
type MitbringselItemDto = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,30 +31,42 @@ export function MitbringselList({
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState("");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!content.trim()) return;
|
if (isPending || !content.trim()) return;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const res = await addMitbringsel({ terminId, content });
|
const res = await addMitbringsel({ terminId, content });
|
||||||
if (res.error) {
|
if (res.error) toast.error(res.error);
|
||||||
toast.error(res.error);
|
else {
|
||||||
} else {
|
|
||||||
toast.success("Eintrag hinzugefügt.");
|
toast.success("Eintrag hinzugefügt.");
|
||||||
setContent("");
|
setContent("");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (isPending || !editingId || !editContent.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await updateMitbringsel({
|
||||||
|
itemId: editingId,
|
||||||
|
content: editContent,
|
||||||
|
});
|
||||||
|
if (res.error) toast.error(res.error);
|
||||||
|
else {
|
||||||
|
toast.success("Beitrag gespeichert.");
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const res = await deleteMitbringsel(id);
|
const res = await deleteMitbringsel(id);
|
||||||
if (res.error) {
|
if (res.error) toast.error(res.error);
|
||||||
toast.error(res.error);
|
else toast.success("Eintrag gelöscht.");
|
||||||
} else {
|
|
||||||
toast.success("Eintrag gelöscht.");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +76,7 @@ export function MitbringselList({
|
|||||||
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
<Utensils className="h-4 w-4 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||||
Mitbring-Liste
|
Mitbring-Liste
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex max-h-60 flex-col gap-2 overflow-y-auto">
|
||||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-xs italic text-[var(--ink-faint)]">
|
<p className="text-xs italic text-[var(--ink-faint)]">
|
||||||
Noch keine Einträge vorhanden.
|
Noch keine Einträge vorhanden.
|
||||||
@@ -70,54 +85,118 @@ export function MitbringselList({
|
|||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
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)]"
|
className="rounded-md border border-[var(--hairline-soft)] bg-[var(--surface)] p-2 text-sm text-[var(--ink)]"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
<span className="text-xs font-medium text-[var(--accent-deep)]">
|
||||||
{item.user.firstName} {item.user.lastName}
|
{item.user.firstName} {item.user.lastName}
|
||||||
</span>
|
</span>
|
||||||
<span>{item.content}</span>
|
{editingId !== item.id ? (
|
||||||
|
<p className="break-words">{item.content}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{(isAdmin || item.userId === currentUserId) && (
|
{editingId !== item.id ? (
|
||||||
|
<div className="flex shrink-0 gap-1">
|
||||||
|
{item.userId === currentUserId ? (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 shrink-0 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
className="h-8 w-8"
|
||||||
|
aria-label={`Beitrag bearbeiten: ${item.content}`}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditContent(item.content);
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{isAdmin || item.userId === currentUserId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-[var(--ink-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||||
|
aria-label={`Beitrag löschen: ${item.content}`}
|
||||||
onClick={() => handleDelete(item.id)}
|
onClick={() => handleDelete(item.id)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<form
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label="Mitbringbeitrag bearbeiten"
|
||||||
|
value={editContent}
|
||||||
|
onChange={(event) => setEditContent(event.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
disabled={isPending}
|
||||||
|
className="h-8 min-w-0 flex-[1_1_180px] text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={isPending || !editContent.trim()}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<form
|
||||||
<div className="flex gap-2 mt-1">
|
className="mt-1 flex gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
|
aria-label="Neuer Mitbringbeitrag"
|
||||||
placeholder="Was bringst du mit?"
|
placeholder="Was bringst du mit?"
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
onKeyDown={(e) => {
|
maxLength={500}
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAdd();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="h-8 text-sm"
|
className="h-8 min-w-0 text-sm"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 shrink-0"
|
className="h-8 shrink-0"
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={!content.trim() || isPending}
|
disabled={!content.trim() || isPending}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4 [stroke-width:1.7]" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Hinzufügen
|
Hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -760,23 +760,18 @@ function NotdienstRow({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : notdienst.canRequestSwap ? (
|
) : null}
|
||||||
|
{notdienst.canRequestSwap ? (
|
||||||
<NotdienstSwapDialog
|
<NotdienstSwapDialog
|
||||||
target={notdienst}
|
target={notdienst}
|
||||||
options={notdienstSwapOptions}
|
options={notdienstSwapOptions}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 px-2.5 text-xs"
|
|
||||||
>
|
|
||||||
Tausch anfragen
|
Tausch anfragen
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : !isAdmin ? <div className="w-9 h-9" /> : null}
|
||||||
<div className="w-9 h-9" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { serializableTransaction } from "@/lib/transaction";
|
|||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { lockEvent } from "@/lib/event-lock";
|
import { lockEvent } from "@/lib/event-lock";
|
||||||
|
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/kalender · Server Actions
|
// /dashboard/kalender · Server Actions
|
||||||
@@ -127,7 +128,7 @@ async function getSwapAssignment(
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: {
|
select: {
|
||||||
email: true,
|
email: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
@@ -335,11 +336,8 @@ function buildTerminData(
|
|||||||
export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." };
|
||||||
if (session.user.role !== UserRole.ELTERN) {
|
if (!canRequestNotdienstSwap(session.user.role, session.user.familyId)) {
|
||||||
return { error: "Nur Eltern können Notdienst-Tausche anfragen." };
|
return { error: "Nur Eltern mit einem eigenen Haushalt können Notdienst-Tausche anfragen." };
|
||||||
}
|
|
||||||
if (!session.user.familyId) {
|
|
||||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = swapRequestSchema.safeParse(rawPayload);
|
const parsed = swapRequestSchema.safeParse(rawPayload);
|
||||||
@@ -467,7 +465,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -493,7 +491,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -636,7 +634,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -654,7 +652,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
|||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: { role: { in: [UserRole.ELTERN, UserRole.ADMIN, UserRole.KOORDINATOR] }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1059,6 +1057,44 @@ export async function addMitbringsel(rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateMitbringselSchema = z.object({
|
||||||
|
itemId: z.string().min(1).max(100),
|
||||||
|
content: mitbringselSchema.shape.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateMitbringsel(rawPayload: unknown) {
|
||||||
|
const session = await requireKitaSession();
|
||||||
|
const parsed = updateMitbringselSchema.safeParse(rawPayload);
|
||||||
|
if (!parsed.success) return { error: "Bitte einen Beitrag mit 1 bis 500 Zeichen eingeben." };
|
||||||
|
if (!(await rateLimit(`updateMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) {
|
||||||
|
return { error: "Zu viele Änderungen. Bitte versuche es später erneut." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serializableTransaction(async (tx) => {
|
||||||
|
const item = await tx.mitbringselItem.findFirst({
|
||||||
|
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
||||||
|
select: { terminId: true },
|
||||||
|
});
|
||||||
|
if (!item) throw new UserFacingError("Du kannst nur eigene Mitbringbeiträge bearbeiten.");
|
||||||
|
await lockEvent(tx, item.terminId, session.user.kitaId);
|
||||||
|
const termin = await tx.termin.findFirst({
|
||||||
|
where: { id: item.terminId, kitaId: session.user.kitaId, status: TerminStatus.CONFIRMED, mitbringselListEnabled: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!termin) throw new UserFacingError("Die Mitbring-Liste ist für diesen Termin nicht mehr aktiv.");
|
||||||
|
await tx.mitbringselItem.update({
|
||||||
|
where: { id: parsed.data.itemId, kitaId: session.user.kitaId, userId: session.user.id },
|
||||||
|
data: { content: parsed.data.content },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
revalidatePath("/dashboard/kalender");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: publicActionError(error, "Der Beitrag konnte nicht gespeichert werden.") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteMitbringsel(itemId: string) {
|
export async function deleteMitbringsel(itemId: string) {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { HelpCircle } from "lucide-react";
|
|||||||
|
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
||||||
import { getRsvpEvents } from "@/lib/rsvp-events";
|
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||||
import {
|
import {
|
||||||
@@ -213,7 +214,7 @@ export default async function KalenderPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pendingSwapRows =
|
const pendingSwapRows =
|
||||||
!isAdmin && session.user.familyId
|
session.user.familyId && canRequestNotdienstSwap(session.user.role, session.user.familyId)
|
||||||
? await prisma.notdienstSwapRequest.findMany({
|
? await prisma.notdienstSwapRequest.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
@@ -306,9 +307,8 @@ export default async function KalenderPage({
|
|||||||
...notdienstAssignmentRows.map((assignment) => {
|
...notdienstAssignmentRows.map((assignment) => {
|
||||||
const isOwn = assignment.child.familyId === familyIdForParticipation;
|
const isOwn = assignment.child.familyId === familyIdForParticipation;
|
||||||
const canRequestSwap =
|
const canRequestSwap =
|
||||||
!isAdmin &&
|
canRequestNotdienstSwap(session.user.role, session.user.familyId) &&
|
||||||
!isOwn &&
|
!isOwn &&
|
||||||
!!session.user.familyId &&
|
|
||||||
assignment.date >= today;
|
assignment.date >= today;
|
||||||
return {
|
return {
|
||||||
kind: "notdienst" as const,
|
kind: "notdienst" as const,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { MarkdownContent } from "@/components/markdown-content";
|
|||||||
|
|
||||||
export type NewsTickerItemDto = {
|
export type NewsTickerItemDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
editedAt: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
@@ -53,16 +55,16 @@ export function NewsTicker({
|
|||||||
|
|
||||||
function openAnnouncement(item: NewsTickerItemDto) {
|
function openAnnouncement(item: NewsTickerItemDto) {
|
||||||
setSelected(item);
|
setSelected(item);
|
||||||
if (!item.isUnread || readIds.has(item.id)) return;
|
if (!item.isUnread || readIds.has(`${item.id}:${item.revision}`)) return;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await markAnnouncementRead(item.id);
|
const result = await markAnnouncementRead(item.id, item.revision);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReadIds((current) => new Set(current).add(item.id));
|
setReadIds((current) => new Set(current).add(`${item.id}:${item.revision}`));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Lesestatus konnte nicht gespeichert werden.");
|
toast.error("Lesestatus konnte nicht gespeichert werden.");
|
||||||
}
|
}
|
||||||
@@ -101,7 +103,7 @@ export function NewsTicker({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isUnread = item.isUnread && !readIds.has(item.id);
|
const isUnread = item.isUnread && !readIds.has(`${item.id}:${item.revision}`);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -116,6 +118,7 @@ export function NewsTicker({
|
|||||||
</div>
|
</div>
|
||||||
{isUnread && <Badge variant="success">NEU</Badge>}
|
{isUnread && <Badge variant="success">NEU</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
{item.editedAt && <p className="mb-2 text-xs font-medium text-[var(--accent-deep)]">Aktualisiert am {item.editedAt}</p>}
|
||||||
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
|
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -146,6 +149,7 @@ export function NewsTicker({
|
|||||||
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
|
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{selected.createdAt} · {selected.authorName}
|
{selected.createdAt} · {selected.authorName}
|
||||||
|
{selected.editedAt && <span className="block">Aktualisiert am {selected.editedAt}</span>}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { UserRole } from "@prisma/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||||
import { getTargetMonthData } from "@/lib/date-utils";
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
|
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/notdienst · Server Actions
|
// /dashboard/notdienst · Server Actions
|
||||||
@@ -64,90 +66,60 @@ export async function saveNotdienstAvailabilities(payloadRaw: {
|
|||||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Validierung (Soll-Tage) ─────────────────────────────────────
|
try {
|
||||||
const kita = await prisma.kita.findUniqueOrThrow({
|
await serializableTransaction(async (tx) => {
|
||||||
|
const [kita, children, workingDays] = await Promise.all([
|
||||||
|
tx.kita.findUniqueOrThrow({
|
||||||
where: { id: kitaId },
|
where: { id: kitaId },
|
||||||
select: { notdienstMinPerChildPerMonth: true },
|
select: { notdienstMinPerChildPerMonth: true },
|
||||||
});
|
}),
|
||||||
|
tx.child.findMany({
|
||||||
const requiredTotal = childrenIds.length * kita.notdienstMinPerChildPerMonth;
|
where: { kitaId, familyId: session.user.familyId!, active: true },
|
||||||
if (dates.length < requiredTotal) {
|
|
||||||
return {
|
|
||||||
error: `Bitte wähle mindestens ${requiredTotal} Tage aus.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sicherstellen, dass alle Kinder zum Haushalt des angemeldeten Users gehören.
|
|
||||||
const validChildren = await prisma.child.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: childrenIds },
|
|
||||||
kitaId,
|
|
||||||
familyId: session.user.familyId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
orderBy: { id: "asc" },
|
||||||
|
}),
|
||||||
if (validChildren.length !== childrenIds.length) {
|
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
|
||||||
return { error: "Ungültige Kinder-Zuordnung erkannt." };
|
]);
|
||||||
|
const suppliedIds = new Set(childrenIds);
|
||||||
|
if (!children.length || suppliedIds.size !== childrenIds.length ||
|
||||||
|
suppliedIds.size !== children.length || !children.every((child) => suppliedIds.has(child.id))) {
|
||||||
|
throw new UserFacingError("Bitte lade die Seite neu und wähle die Termine für alle aktiven Kinder deines Haushalts aus.");
|
||||||
}
|
}
|
||||||
|
const validDates = new Set(workingDays.map(formatUtcDateKey));
|
||||||
// Termine in Date-Objekte wandeln und sortieren
|
if (dates.some((date) => !validDates.has(date))) {
|
||||||
const parsedDates = dates.map(dateKeyToUtcDate);
|
throw new UserFacingError("Bitte wähle nur geöffnete Werktage im Zielmonat aus. Wochenenden und Schließtage sind nicht möglich.");
|
||||||
const hasOutsideTargetMonth = parsedDates.some(
|
|
||||||
(date) =>
|
|
||||||
date.getUTCFullYear() !== targetYear ||
|
|
||||||
date.getUTCMonth() !== targetMonth - 1,
|
|
||||||
);
|
|
||||||
if (hasOutsideTargetMonth) {
|
|
||||||
return { error: "Bitte wähle Termine im Zielmonat aus." };
|
|
||||||
}
|
}
|
||||||
|
const requiredTotal = children.length * kita.notdienstMinPerChildPerMonth;
|
||||||
// Round-Robin Zuweisung
|
if (dates.length < requiredTotal) {
|
||||||
const inserts: {
|
throw new UserFacingError(`Bitte wähle mindestens ${requiredTotal} Tage aus.`);
|
||||||
kitaId: string;
|
|
||||||
childId: string;
|
|
||||||
userId: string;
|
|
||||||
date: Date;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < parsedDates.length; i++) {
|
|
||||||
const childId = validChildren[i % validChildren.length].id;
|
|
||||||
inserts.push({
|
|
||||||
kitaId,
|
|
||||||
childId,
|
|
||||||
userId: session.user.id,
|
|
||||||
date: parsedDates[i],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const activeIds = children.map((child) => child.id);
|
||||||
// ── 3. Datenbank-Transaktion ───────────────────────────────────────
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
// a) Alle bestehenden Haushaltseinträge im Zielmonat für diese Kinder löschen
|
|
||||||
await tx.notdienstAvailability.deleteMany({
|
await tx.notdienstAvailability.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
childId: { in: childrenIds },
|
child: { kitaId, familyId: session.user.familyId! },
|
||||||
date: {
|
date: {
|
||||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// b) Neue Einträge setzen
|
|
||||||
if (inserts.length > 0) {
|
|
||||||
await tx.notdienstAvailability.createMany({
|
await tx.notdienstAvailability.createMany({
|
||||||
data: inserts,
|
data: dates.map((date, index) => ({
|
||||||
skipDuplicates: true,
|
kitaId,
|
||||||
|
childId: activeIds[index % activeIds.length],
|
||||||
|
userId: session.user.id,
|
||||||
|
date: dateKeyToUtcDate(date),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard/notdienst");
|
revalidatePath("/dashboard/notdienst");
|
||||||
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
return { success: true, dates };
|
return { success: true, dates };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Speichern der Notdienste:", error);
|
if (!(error instanceof UserFacingError)) console.error("Fehler beim Speichern der Notdienste:", error);
|
||||||
return { error: "Ein Fehler ist aufgetreten beim Speichern der Termine." };
|
return { error: publicActionError(error, "Ein Fehler ist aufgetreten beim Speichern der Termine.") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { de } from "date-fns/locale";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatDateKey, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import { formatDateKey, formatUtcDateKey } from "@/lib/date-utils";
|
||||||
|
import { getNotdienstDays } from "@/lib/notdienst-days";
|
||||||
import { saveNotdienstAvailabilities } from "./actions";
|
import { saveNotdienstAvailabilities } from "./actions";
|
||||||
|
|
||||||
export const notdienstEventCategories = {
|
export const notdienstEventCategories = {
|
||||||
@@ -100,23 +101,21 @@ export function NotdienstForm({
|
|||||||
}: NotdienstFormProps) {
|
}: NotdienstFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
|
// Lokaler State für die Auswahl. Ein Datum liegt als stabiler yyyy-MM-dd-Schlüssel vor.
|
||||||
const [selectedDates, setSelectedDates] = useState<Set<string>>(
|
const [selection, setSelectedDates] = useState<Set<string>>(
|
||||||
new Set(initialSelectedDates),
|
new Set(initialSelectedDates),
|
||||||
);
|
);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const closureDateKeys = useMemo(
|
const selectableDateKeys = useMemo(
|
||||||
() => getClosureDateKeys(calendarItems),
|
() => new Set(getNotdienstDays(targetYear, targetMonth,
|
||||||
[calendarItems],
|
calendarItems.filter((item) => item.category === "closure" || item.category === "team"),
|
||||||
|
).map(formatUtcDateKey)),
|
||||||
|
[calendarItems, targetMonth, targetYear],
|
||||||
);
|
);
|
||||||
const selectableDateKeys = useMemo(() => {
|
const selectedDates = useMemo(
|
||||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
() => new Set(Array.from(selection).filter((key) => selectableDateKeys.has(key))),
|
||||||
return new Set(
|
[selection, selectableDateKeys],
|
||||||
workingDays
|
|
||||||
.map(formatDateKey)
|
|
||||||
.filter((dateKey) => !closureDateKeys.has(dateKey)),
|
|
||||||
);
|
);
|
||||||
}, [closureDateKeys, targetMonth, targetYear]);
|
|
||||||
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
|
const monthStart = startOfMonth(new Date(targetYear, targetMonth - 1));
|
||||||
const selectedCount = selectedDates.size;
|
const selectedCount = selectedDates.size;
|
||||||
const hasMinimumRequirement = requiredDaysTotal > 0;
|
const hasMinimumRequirement = requiredDaysTotal > 0;
|
||||||
@@ -375,12 +374,9 @@ export function NotdienstCalendar({
|
|||||||
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
|
const isInTargetMonth = isSameMonth(day, visibleMonthStart);
|
||||||
const isSelected = selected.has(dateKey);
|
const isSelected = selected.has(dateKey);
|
||||||
const dayEvents = getCalendarItemsForDay(events, day);
|
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;
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
||||||
|
const hasClosure = isInTargetMonth && !isWeekend && !selectableDateKeys.has(dateKey);
|
||||||
|
const isSelectable = isInTargetMonth && selectableDateKeys.has(dateKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarTile
|
<CalendarTile
|
||||||
@@ -603,25 +599,6 @@ function getCalendarItemsForDay(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function formatEventDate(event: NotdienstCalendarItem) {
|
||||||
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
|
const date = format(event.startDate, "EEEE, d. MMMM yyyy", { locale: de });
|
||||||
return event.allDay
|
return event.allDay
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Manrope } from "next/font/google";
|
|||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { formatDateKey, getTargetMonthData } from "@/lib/date-utils";
|
import { formatUtcDateKey, getTargetMonthData } from "@/lib/date-utils";
|
||||||
|
import { getNotdienstDays } from "@/lib/notdienst-days";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { NotdienstEntry } from "./notdienst-entry";
|
import { NotdienstEntry } from "./notdienst-entry";
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export default async function NotdienstPage() {
|
|||||||
kitaId,
|
kitaId,
|
||||||
status: TerminStatus.CONFIRMED,
|
status: TerminStatus.CONFIRMED,
|
||||||
startDate: { lt: targetMonthEnd },
|
startDate: { lt: targetMonthEnd },
|
||||||
endDate: { gte: targetMonthStart },
|
endDate: { gte: new Date(targetMonthStart.getTime() - 86400000) },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -93,7 +94,10 @@ export default async function NotdienstPage() {
|
|||||||
])
|
])
|
||||||
: [[], []];
|
: [[], []];
|
||||||
|
|
||||||
const selectedDates = availabilities.map((a) => formatDateKey(a.date));
|
const validDateKeys = new Set(getNotdienstDays(targetYear, targetMonth,
|
||||||
|
termine.filter((termin) => termin.type === TerminType.SCHLIESSTAG || termin.type === TerminType.TEAMTAG),
|
||||||
|
).map(formatUtcDateKey));
|
||||||
|
const selectedDates = availabilities.map((a) => formatUtcDateKey(a.date)).filter((key) => validDateKeys.has(key));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
|
<div className={`${manrope.className} mx-auto max-w-7xl px-4 py-8 sm:px-8`}>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { NotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
||||||
|
import {
|
||||||
|
retryNotdienstEmailsAction,
|
||||||
|
sendNotdienstRemindersAction,
|
||||||
|
} from "../actions";
|
||||||
|
|
||||||
|
export function EmailStatusCard({
|
||||||
|
status,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
remindersOpen,
|
||||||
|
daysBefore,
|
||||||
|
jobConfigured,
|
||||||
|
}: {
|
||||||
|
status: NotdienstEmailStatus;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
remindersOpen: boolean;
|
||||||
|
daysBefore: number;
|
||||||
|
jobConfigured: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const run = (retry: boolean) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = retry
|
||||||
|
? await retryNotdienstEmailsAction(year, month)
|
||||||
|
: await sendNotdienstRemindersAction(year, month);
|
||||||
|
if (result.error) toast.error(result.error);
|
||||||
|
else if ("failed" in result && result.failed)
|
||||||
|
toast.warning(
|
||||||
|
"Einige E-Mails sind noch nicht versendet. Der nächste Versuch erfolgt automatisch oder über diesen Button.",
|
||||||
|
);
|
||||||
|
else if ("sent" in result)
|
||||||
|
toast.success(`${result.sent} E-Mails versendet.`);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
const labels = {
|
||||||
|
sent: "Versendet",
|
||||||
|
pending: "Wartet auf Versand",
|
||||||
|
failed: "Versand fehlgeschlagen",
|
||||||
|
skipped: "Nicht mehr erforderlich",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card className="mb-5 print:hidden p-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-[var(--ink)]">
|
||||||
|
E-Mail-Versand
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
|
{daysBefore
|
||||||
|
? `Automatische Erinnerung ab dem ${Math.max(1, 26 - daysBefore)}. um 9 Uhr. Eintragsfrist: der 26.`
|
||||||
|
: "Automatische Erinnerungen sind ausgeschaltet."}
|
||||||
|
</p>
|
||||||
|
{!jobConfigured ? (
|
||||||
|
<p className="mt-1 text-xs text-[var(--warn)]">
|
||||||
|
Der Hintergrundversand ist noch nicht konfiguriert. Offene E-Mails
|
||||||
|
können hier manuell gesendet werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{remindersOpen ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => run(false)}
|
||||||
|
>
|
||||||
|
An fehlende Verfügbarkeiten erinnern
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{status.failed + status.pending > 0 ? (
|
||||||
|
<Button size="sm" disabled={pending} onClick={() => run(true)}>
|
||||||
|
Offene E-Mails erneut senden
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => router.refresh()}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-[var(--ink-soft)]">
|
||||||
|
{status.sent} versendet · {status.pending} ausstehend · {status.failed}{" "}
|
||||||
|
fehlgeschlagen · {status.skipped} nicht mehr erforderlich
|
||||||
|
</p>
|
||||||
|
{status.entries.length ? (
|
||||||
|
<details className="mt-3 text-sm">
|
||||||
|
<summary className="cursor-pointer font-medium text-[var(--accent-deep)]">
|
||||||
|
Versanddetails ansehen
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-3 max-h-64 overflow-y-auto divide-y divide-[var(--hairline-soft)]">
|
||||||
|
{status.entries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex flex-wrap justify-between gap-2 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{entry.name} · {entry.familyName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
|
{entry.kind === "REMINDER"
|
||||||
|
? "Verfügbarkeitserinnerung"
|
||||||
|
: "Planveröffentlichung"}
|
||||||
|
</p>
|
||||||
|
{entry.error ? (
|
||||||
|
<p className="mt-1 text-xs text-[var(--danger)]">
|
||||||
|
{entry.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
entry.status === "failed"
|
||||||
|
? "text-[var(--danger)]"
|
||||||
|
: "text-[var(--ink-soft)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{labels[entry.status]}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-[var(--ink-muted)]">
|
||||||
|
Für diesen Monat wurden noch keine E-Mails vorbereitet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 text-xs text-[var(--ink-muted)]">
|
||||||
|
Versendet bedeutet: vom Maildienst angenommen. Die tatsächliche
|
||||||
|
Zustellung ins Postfach kann hier nicht bestätigt werden.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,8 +35,8 @@ export function SettingsModal({
|
|||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (minPerChild < 0 || reminderDays < 0) {
|
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) {
|
||||||
toast.error("Werte dürfen nicht negativ sein.");
|
toast.error("Bitte ein Soll von 1 bis 31 und eine Erinnerungsfrist von 0 bis 31 Tagen eingeben.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,25 +74,26 @@ export function SettingsModal({
|
|||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="minPerChild">
|
<Label htmlFor="minPerChild">
|
||||||
Mindesteinteilungen pro Kind/Monat
|
Verfügbarkeitstage pro Kind/Monat
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="minPerChild"
|
id="minPerChild"
|
||||||
name="minPerChild"
|
name="minPerChild"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="1"
|
||||||
|
max="31"
|
||||||
required
|
required
|
||||||
value={minPerChild}
|
value={minPerChild}
|
||||||
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
|
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
|
So viele unterschiedliche Tage müssen Eltern je aktivem Kind als Verfügbarkeit eintragen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="reminderDays">
|
<Label htmlFor="reminderDays">
|
||||||
Erinnerungsfrist (Tage vor Monatsende)
|
Erinnerung (Tage vor Eintragsfrist)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="reminderDays"
|
id="reminderDays"
|
||||||
@@ -100,11 +101,12 @@ export function SettingsModal({
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
required
|
required
|
||||||
|
max="31"
|
||||||
value={reminderDays}
|
value={reminderDays}
|
||||||
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
|
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--ink-muted)]">
|
<p className="text-xs text-[var(--ink-muted)]">
|
||||||
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
|
Die Eintragsfrist endet am 26. Bei 7 Tagen erfolgt die Erinnerung ab dem 19. um 9 Uhr. 0 deaktiviert automatische Erinnerungen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ import { de } from "date-fns/locale";
|
|||||||
|
|
||||||
import { requireRole } from "@/lib/auth-utils";
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import { getTargetMonthData } from "@/lib/date-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
import { todayKey } from "@/lib/alarms/format";
|
||||||
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import {
|
||||||
|
queueNotdienstPublication,
|
||||||
|
queueNotdienstReminders,
|
||||||
|
deliverDueNotdienstEmails,
|
||||||
|
} from "@/lib/notdienst-notifications";
|
||||||
|
import { sendAppEmail } from "@/lib/mail";
|
||||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
import {
|
import {
|
||||||
@@ -40,77 +47,53 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
targetMonth = data.targetMonth;
|
targetMonth = data.targetMonth;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
if (!validPlanMonth(targetYear, targetMonth))
|
||||||
|
return { error: "Ungültiger Planungsmonat." };
|
||||||
|
|
||||||
// Prüfen, ob schon ein Plan existiert
|
try {
|
||||||
const existingPlan = await prisma.notdienstPlan.findUnique({
|
await serializableTransaction(async (tx) => {
|
||||||
|
const existingPlan = await tx.notdienstPlan.findUnique({
|
||||||
where: {
|
where: {
|
||||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (existingPlan)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Ein Plan für diesen Monat existiert bereits.",
|
||||||
|
);
|
||||||
|
|
||||||
if (existingPlan) {
|
const [availabilities, workingDays] = await Promise.all([
|
||||||
return { error: "Ein Plan für diesen Monat existiert bereits." };
|
tx.notdienstAvailability.findMany({
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
|
|
||||||
const availabilities = await prisma.notdienstAvailability.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
|
child: { kitaId, active: true },
|
||||||
date: {
|
date: {
|
||||||
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||||
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: { child: { select: { familyId: true } } },
|
include: { child: { select: { familyId: true } } },
|
||||||
});
|
}),
|
||||||
|
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
|
||||||
// 2. Werktage holen
|
]);
|
||||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
const familyUsageCount = new Map<string, number>();
|
||||||
|
const assignments: { childId: string; date: Date }[] = [];
|
||||||
// 3. Algorithmus: Wir verteilen die Slots
|
|
||||||
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
|
|
||||||
const familyUsageCount: Record<string, number> = {};
|
|
||||||
|
|
||||||
const assignmentsToCreate: { childId: string; date: Date }[] = [];
|
|
||||||
|
|
||||||
for (const day of workingDays) {
|
for (const day of workingDays) {
|
||||||
// Alle Verfügbarkeiten für diesen spezifischen Tag
|
const candidates = availabilities.filter(
|
||||||
const availForDay = availabilities.filter(
|
|
||||||
(a) => a.date.getTime() === day.getTime(),
|
(a) => a.date.getTime() === day.getTime(),
|
||||||
);
|
);
|
||||||
|
if (!candidates.length) continue;
|
||||||
if (availForDay.length === 0) {
|
const minUsage = Math.min(
|
||||||
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
|
...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0),
|
||||||
continue;
|
);
|
||||||
|
const leastUsed = candidates.filter(
|
||||||
|
(a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage,
|
||||||
|
);
|
||||||
|
const selected =
|
||||||
|
leastUsed[Math.floor(Math.random() * leastUsed.length)];
|
||||||
|
assignments.push({ childId: selected.childId, date: day });
|
||||||
|
familyUsageCount.set(selected.child.familyId, minUsage + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finde die Familie, die am wenigsten oft dran war
|
|
||||||
// Zufall einbauen bei Gleichstand
|
|
||||||
const sorted = availForDay.sort((a, b) => {
|
|
||||||
const countA = familyUsageCount[a.child.familyId] || 0;
|
|
||||||
const countB = familyUsageCount[b.child.familyId] || 0;
|
|
||||||
if (countA === countB) {
|
|
||||||
return Math.random() - 0.5; // Zufallsmischung
|
|
||||||
}
|
|
||||||
return countA - countB;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selected = sorted[0];
|
|
||||||
|
|
||||||
assignmentsToCreate.push({
|
|
||||||
childId: selected.childId,
|
|
||||||
date: day,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Usage-Counter erhöhen
|
|
||||||
familyUsageCount[selected.child.familyId] =
|
|
||||||
(familyUsageCount[selected.child.familyId] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. In der DB speichern
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
const plan = await tx.notdienstPlan.create({
|
const plan = await tx.notdienstPlan.create({
|
||||||
data: {
|
data: {
|
||||||
kitaId,
|
kitaId,
|
||||||
@@ -120,15 +103,9 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
createdById: session.user.id,
|
createdById: session.user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (assignments.length) {
|
||||||
if (assignmentsToCreate.length > 0) {
|
|
||||||
await tx.notdienstAssignment.createMany({
|
await tx.notdienstAssignment.createMany({
|
||||||
data: assignmentsToCreate.map((a) => ({
|
data: assignments.map((a) => ({ ...a, kitaId, planId: plan.id })),
|
||||||
kitaId,
|
|
||||||
planId: plan.id,
|
|
||||||
childId: a.childId,
|
|
||||||
date: a.date,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -137,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return { error: "Fehler bei der Generierung des Plans." };
|
return {
|
||||||
|
error: publicActionError(error, "Fehler bei der Generierung des Plans."),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,17 +124,60 @@ export async function publishPlanAction(planId: string) {
|
|||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = session.user.kitaId!;
|
||||||
|
|
||||||
await prisma.notdienstPlan.update({
|
try {
|
||||||
|
await serializableTransaction(async (tx) => {
|
||||||
|
const plan = await tx.notdienstPlan.findUnique({
|
||||||
|
where: { id: planId, kitaId },
|
||||||
|
include: {
|
||||||
|
assignments: { include: { child: { select: { active: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
|
||||||
|
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
|
||||||
|
if (plan.status !== NotdienstPlanStatus.DRAFT)
|
||||||
|
throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
|
||||||
|
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||||
|
const validDates = new Set(days.map((day) => day.getTime()));
|
||||||
|
if (
|
||||||
|
plan.assignments.some(
|
||||||
|
(a) => !validDates.has(a.date.getTime()) || !a.child.active,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx.notdienstPlan.update({
|
||||||
where: { id: planId, kitaId },
|
where: { id: planId, kitaId },
|
||||||
data: {
|
data: {
|
||||||
status: NotdienstPlanStatus.PUBLISHED,
|
status: NotdienstPlanStatus.PUBLISHED,
|
||||||
publishedAt: new Date(),
|
publishedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await queueNotdienstPublication(
|
||||||
|
tx,
|
||||||
|
kitaId,
|
||||||
|
planId,
|
||||||
|
plan.year,
|
||||||
|
plan.month,
|
||||||
|
);
|
||||||
|
});
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
revalidatePath("/dashboard/notdienst");
|
revalidatePath("/dashboard/notdienst");
|
||||||
return { success: true };
|
revalidatePath("/dashboard/kalender");
|
||||||
|
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(
|
||||||
|
() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }),
|
||||||
|
);
|
||||||
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
|
return { success: true, ...delivery };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Der Plan konnte nicht veröffentlicht werden.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAssignmentAction(
|
export async function updateAssignmentAction(
|
||||||
@@ -166,7 +188,8 @@ export async function updateAssignmentAction(
|
|||||||
const actor = await requireAlarmActor();
|
const actor = await requireAlarmActor();
|
||||||
const kitaId = actor.kitaId;
|
const kitaId = actor.kitaId;
|
||||||
|
|
||||||
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
|
if (!dateOnlySchema.safeParse(date).success)
|
||||||
|
return { error: "Ungültiges Datum." };
|
||||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||||
|
|
||||||
@@ -189,11 +212,32 @@ export async function updateAssignmentAction(
|
|||||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPublished) {
|
if (
|
||||||
const today = new Date();
|
newChildId &&
|
||||||
const todayUtc = new Date(
|
(parsedDate.getUTCFullYear() !== plan.year ||
|
||||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
parsedDate.getUTCMonth() + 1 !== plan.month)
|
||||||
|
) {
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Das Datum gehört nicht zum Planungsmonat.",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
// Clearing obsolete entries remains possible after a new closure.
|
||||||
|
if (newChildId) {
|
||||||
|
const days = await loadNotdienstDays(
|
||||||
|
tx,
|
||||||
|
kitaId,
|
||||||
|
plan.year,
|
||||||
|
plan.month,
|
||||||
|
);
|
||||||
|
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
|
||||||
|
throw new UserFacingError(
|
||||||
|
"An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPublished) {
|
||||||
|
const todayUtc = new Date(`${todayKey()}T00:00:00Z`);
|
||||||
if (parsedDate < todayUtc) {
|
if (parsedDate < todayUtc) {
|
||||||
throw new UserFacingError(
|
throw new UserFacingError(
|
||||||
"Vergangene Notdienste können nicht geändert werden.",
|
"Vergangene Notdienste können nicht geändert werden.",
|
||||||
@@ -202,14 +246,25 @@ export async function updateAssignmentAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||||
where: { planId, date: parsedDate },
|
where: { kitaId, planId, date: parsedDate },
|
||||||
include: {
|
include: {
|
||||||
child: {
|
child: {
|
||||||
include: {
|
include: {
|
||||||
family: {
|
family: {
|
||||||
include: {
|
include: {
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: {
|
||||||
|
role: {
|
||||||
|
in: [
|
||||||
|
UserRole.ELTERN,
|
||||||
|
UserRole.ADMIN,
|
||||||
|
UserRole.KOORDINATOR,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
},
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -237,7 +292,18 @@ export async function updateAssignmentAction(
|
|||||||
family: {
|
family: {
|
||||||
include: {
|
include: {
|
||||||
users: {
|
users: {
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
where: {
|
||||||
|
role: {
|
||||||
|
in: [
|
||||||
|
UserRole.ELTERN,
|
||||||
|
UserRole.ADMIN,
|
||||||
|
UserRole.KOORDINATOR,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
emailVerifiedAt: { not: null },
|
||||||
|
disabledAt: null,
|
||||||
|
privacyPolicyAcceptedAt: { not: null },
|
||||||
|
},
|
||||||
select: { email: true, firstName: true, lastName: true },
|
select: { email: true, firstName: true, lastName: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -412,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction(
|
|||||||
return { error: "Kein Mandant zugeordnet." };
|
return { error: "Kein Mandant zugeordnet." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
|
if (
|
||||||
|
!Number.isInteger(minPerChild) ||
|
||||||
|
minPerChild < 1 ||
|
||||||
|
minPerChild > 31 ||
|
||||||
|
!Number.isInteger(reminderDays) ||
|
||||||
|
reminderDays < 0 ||
|
||||||
|
reminderDays > 31
|
||||||
|
)
|
||||||
|
return { error: "Ungültige Einstellungen." };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.kita.update({
|
await prisma.kita.update({
|
||||||
@@ -436,113 +510,56 @@ export async function sendNotdienstRemindersAction(
|
|||||||
month: number,
|
month: number,
|
||||||
) {
|
) {
|
||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId;
|
const kitaId = session.user.kitaId!;
|
||||||
if (!kitaId) {
|
if (!validPlanMonth(year, month))
|
||||||
return { error: "Kein Mandant zugeordnet." };
|
return { error: "Ungültiger Planungsmonat." };
|
||||||
}
|
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed)
|
||||||
|
return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
|
||||||
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
|
||||||
const mailConfigError = getAppEmailConfigError();
|
|
||||||
if (mailConfigError) {
|
|
||||||
return { error: mailConfigError };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Get Kita Name
|
const queued = await queueNotdienstReminders(kitaId, year, month);
|
||||||
const kita = await prisma.kita.findUnique({
|
const delivery = await deliverDueNotdienstEmails({
|
||||||
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,
|
kitaId,
|
||||||
children: { some: { active: true } },
|
year,
|
||||||
},
|
month,
|
||||||
include: {
|
}).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||||
users: {
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
return { success: true, queued, count: delivery.sent, ...delivery };
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Versenden der Erinnerungen:", error);
|
return {
|
||||||
return { error: "Fehler beim Versenden der Erinnerungen." };
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Die Erinnerungen konnten nicht vorbereitet werden.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryNotdienstEmailsAction(year: number, month: number) {
|
||||||
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
|
const kitaId = session.user.kitaId!;
|
||||||
|
if (!validPlanMonth(year, month))
|
||||||
|
return { error: "Ungültiger Planungsmonat." };
|
||||||
|
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed)
|
||||||
|
return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
||||||
|
try {
|
||||||
|
await prisma.notdienstEmail.updateMany({
|
||||||
|
where: {
|
||||||
|
kitaId,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
failed: true,
|
||||||
|
sentAt: null,
|
||||||
|
skippedAt: null,
|
||||||
|
},
|
||||||
|
data: { nextAttemptAt: new Date() },
|
||||||
|
});
|
||||||
|
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
|
||||||
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
|
return { success: true, ...delivery };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
error: "Der Versand bleibt gespeichert und wird erneut versucht.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { addMonths, getYear, getMonth, format } from "date-fns";
|
import { addMonths, getYear, getMonth, format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { TerminType } from "@prisma/client";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import {
|
||||||
|
formatUtcDateKey,
|
||||||
|
getWorkingDaysOfMonth,
|
||||||
|
getTargetMonthData,
|
||||||
|
} from "@/lib/date-utils";
|
||||||
|
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||||
|
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
||||||
|
import { EmailStatusCard } from "./_components/email-status-card";
|
||||||
import { PlanView } from "./plan-view";
|
import { PlanView } from "./plan-view";
|
||||||
import { SettingsModal } from "./_components/settings-modal";
|
import { SettingsModal } from "./_components/settings-modal";
|
||||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||||
@@ -23,22 +29,21 @@ export default async function PlanungsZentralePage({
|
|||||||
|
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nextMonth = addMonths(now, 1);
|
const defaultTarget = getTargetMonthData(now);
|
||||||
const targetYear = resolvedSearchParams.year
|
const targetYear = resolvedSearchParams.year
|
||||||
? parseInt(resolvedSearchParams.year, 10)
|
? parseInt(resolvedSearchParams.year, 10)
|
||||||
: getYear(nextMonth);
|
: defaultTarget.targetYear;
|
||||||
const targetMonth = resolvedSearchParams.month
|
const targetMonth = resolvedSearchParams.month
|
||||||
? parseInt(resolvedSearchParams.month, 10)
|
? parseInt(resolvedSearchParams.month, 10)
|
||||||
: getMonth(nextMonth) + 1;
|
: defaultTarget.targetMonth;
|
||||||
|
|
||||||
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
||||||
const monthName = format(monthDate, "MMMM", { locale: de });
|
const monthName = format(monthDate, "MMMM", { locale: de });
|
||||||
|
|
||||||
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
const monthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
|
||||||
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
const monthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
|
||||||
const monthEnd = new Date(targetYear, targetMonth, 1);
|
|
||||||
|
|
||||||
const [plan, families, availabilities, closureCount, kita] =
|
const [plan, families, rawAvailabilities, workingDays, kita] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.notdienstPlan.findUnique({
|
prisma.notdienstPlan.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -80,6 +85,7 @@ export default async function PlanungsZentralePage({
|
|||||||
prisma.notdienstAvailability.findMany({
|
prisma.notdienstAvailability.findMany({
|
||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
|
child: { kitaId, active: true },
|
||||||
date: {
|
date: {
|
||||||
gte: monthStart,
|
gte: monthStart,
|
||||||
lt: monthEnd,
|
lt: monthEnd,
|
||||||
@@ -95,16 +101,7 @@ export default async function PlanungsZentralePage({
|
|||||||
},
|
},
|
||||||
orderBy: { date: "asc" },
|
orderBy: { date: "asc" },
|
||||||
}),
|
}),
|
||||||
prisma.termin.count({
|
loadNotdienstDays(prisma, kitaId, targetYear, targetMonth),
|
||||||
where: {
|
|
||||||
kitaId,
|
|
||||||
type: TerminType.SCHLIESSTAG,
|
|
||||||
startDate: {
|
|
||||||
gte: monthStart,
|
|
||||||
lt: monthEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.kita.findUniqueOrThrow({
|
prisma.kita.findUniqueOrThrow({
|
||||||
where: { id: kitaId },
|
where: { id: kitaId },
|
||||||
select: {
|
select: {
|
||||||
@@ -115,8 +112,6 @@ export default async function PlanungsZentralePage({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
||||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
|
||||||
// aber für MVP reichen alle aktiven Kinder.
|
|
||||||
const allChildrenRaw = await prisma.child.findMany({
|
const allChildrenRaw = await prisma.child.findMany({
|
||||||
where: { kitaId, active: true },
|
where: { kitaId, active: true },
|
||||||
select: {
|
select: {
|
||||||
@@ -152,6 +147,13 @@ export default async function PlanungsZentralePage({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
|
||||||
|
const availabilities = rawAvailabilities.filter((a) =>
|
||||||
|
validDateKeys.has(formatUtcDateKey(a.date)),
|
||||||
|
);
|
||||||
|
const closureCount =
|
||||||
|
getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
||||||
|
|
||||||
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
||||||
availabilities.forEach((availability) => {
|
availabilities.forEach((availability) => {
|
||||||
const familyId = availability.child.familyId;
|
const familyId = availability.child.familyId;
|
||||||
@@ -174,11 +176,12 @@ export default async function PlanungsZentralePage({
|
|||||||
}).format(new Date(date)),
|
}).format(new Date(date)),
|
||||||
})),
|
})),
|
||||||
childCount: family.children.length,
|
childCount: family.children.length,
|
||||||
|
requiredDays: family.children.length * kita.notdienstMinPerChildPerMonth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const respondedFamilies = familyStatuses.filter(
|
const respondedFamilies = familyStatuses.filter(
|
||||||
(family) => family.selectedDays.length > 0,
|
(family) => family.selectedDays.length >= family.requiredDays,
|
||||||
).length;
|
).length;
|
||||||
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
|
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
|
||||||
const totalAvailabilitySlots = availabilities.length;
|
const totalAvailabilitySlots = availabilities.length;
|
||||||
@@ -187,7 +190,17 @@ export default async function PlanungsZentralePage({
|
|||||||
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
|
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
|
||||||
|
|
||||||
// Wir mappen die Assignments auf die Werktage
|
// Wir mappen die Assignments auf die Werktage
|
||||||
const daysWithAssignments = workingDays.map((day) => {
|
// Keep obsolete assignments visible so the coordinator can remove them.
|
||||||
|
const displayedDays = new Map(
|
||||||
|
workingDays.map((day) => [formatUtcDateKey(day), day]),
|
||||||
|
);
|
||||||
|
for (const assignment of plan?.assignments ?? []) {
|
||||||
|
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
|
||||||
|
}
|
||||||
|
const activeChildIds = new Set(allChildren.map((child) => child.id));
|
||||||
|
const daysWithAssignments = Array.from(displayedDays.values())
|
||||||
|
.sort((a, b) => a.getTime() - b.getTime())
|
||||||
|
.map((day) => {
|
||||||
const assignment = plan?.assignments.find(
|
const assignment = plan?.assignments.find(
|
||||||
(a) => a.date.getTime() === day.getTime(),
|
(a) => a.date.getTime() === day.getTime(),
|
||||||
);
|
);
|
||||||
@@ -195,19 +208,36 @@ export default async function PlanungsZentralePage({
|
|||||||
!assignment ||
|
!assignment ||
|
||||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||||
|
|
||||||
|
const isInvalid =
|
||||||
|
!validDateKeys.has(formatUtcDateKey(day)) ||
|
||||||
|
(!!assignment && !activeChildIds.has(assignment.childId));
|
||||||
return {
|
return {
|
||||||
date: day.toISOString(),
|
date: day.toISOString(),
|
||||||
|
isInvalid,
|
||||||
assignmentId: assignment?.id || null,
|
assignmentId: assignment?.id || null,
|
||||||
childId: assignment?.childId || null,
|
childId: assignment?.childId || null,
|
||||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||||
canAlarm:
|
canAlarm:
|
||||||
!!assignment &&
|
!!assignment &&
|
||||||
|
!isInvalid &&
|
||||||
plan?.status === "PUBLISHED" &&
|
plan?.status === "PUBLISHED" &&
|
||||||
day.toISOString().slice(0, 10) >= todayKey(),
|
day.toISOString().slice(0, 10) >= todayKey(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mailStatus = await getNotdienstEmailStatus(
|
||||||
|
kitaId,
|
||||||
|
targetYear,
|
||||||
|
targetMonth,
|
||||||
|
);
|
||||||
|
const target = getTargetMonthData(now);
|
||||||
|
const remindersOpen =
|
||||||
|
!target.isLocked &&
|
||||||
|
target.targetYear === targetYear &&
|
||||||
|
target.targetMonth === targetMonth &&
|
||||||
|
plan?.status !== "PUBLISHED";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<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">
|
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
|
||||||
@@ -226,7 +256,7 @@ export default async function PlanungsZentralePage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="print:hidden flex shrink-0 items-center gap-2">
|
<div className="print:hidden flex flex-wrap items-center gap-2">
|
||||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
|
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
|
||||||
@@ -251,8 +281,18 @@ export default async function PlanungsZentralePage({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<EmailStatusCard
|
||||||
|
status={mailStatus}
|
||||||
|
year={targetYear}
|
||||||
|
month={targetMonth}
|
||||||
|
remindersOpen={remindersOpen}
|
||||||
|
daysBefore={kita.notdienstReminderDaysBefore}
|
||||||
|
jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32}
|
||||||
|
/>
|
||||||
|
|
||||||
<PlanView
|
<PlanView
|
||||||
planId={plan?.id}
|
planId={plan?.id}
|
||||||
|
remindersOpen={remindersOpen}
|
||||||
status={plan?.status}
|
status={plan?.status}
|
||||||
days={daysWithAssignments}
|
days={daysWithAssignments}
|
||||||
allChildren={allChildren}
|
allChildren={allChildren}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
|
|
||||||
interface PlanViewProps {
|
interface PlanViewProps {
|
||||||
planId?: string;
|
planId?: string;
|
||||||
|
remindersOpen: boolean;
|
||||||
status?: NotdienstPlanStatus;
|
status?: NotdienstPlanStatus;
|
||||||
days: {
|
days: {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -45,6 +46,7 @@ interface PlanViewProps {
|
|||||||
canAlarm?: boolean;
|
canAlarm?: boolean;
|
||||||
childId: string | null;
|
childId: string | null;
|
||||||
isOriginallyUnassigned?: boolean;
|
isOriginallyUnassigned?: boolean;
|
||||||
|
isInvalid?: boolean;
|
||||||
}[];
|
}[];
|
||||||
allChildren: {
|
allChildren: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,6 +58,7 @@ interface PlanViewProps {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
|
requiredDays: number;
|
||||||
selectedDays: {
|
selectedDays: {
|
||||||
iso: string;
|
iso: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -75,6 +78,7 @@ interface PlanViewProps {
|
|||||||
|
|
||||||
export function PlanView({
|
export function PlanView({
|
||||||
planId,
|
planId,
|
||||||
|
remindersOpen,
|
||||||
status,
|
status,
|
||||||
days,
|
days,
|
||||||
allChildren,
|
allChildren,
|
||||||
@@ -116,8 +120,15 @@ export function PlanView({
|
|||||||
const result = await publishPlanAction(planId);
|
const result = await publishPlanAction(planId);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else {
|
} else if ("success" in result && result.success) {
|
||||||
toast.success("Plan veröffentlicht! Er ist nun fix.");
|
if (result.failed || ("deferred" in result && result.deferred))
|
||||||
|
toast.warning(
|
||||||
|
"Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.",
|
||||||
|
);
|
||||||
|
else
|
||||||
|
toast.success(
|
||||||
|
"Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -127,9 +138,14 @@ export function PlanView({
|
|||||||
const result = await sendNotdienstRemindersAction(year, month);
|
const result = await sendNotdienstRemindersAction(year, month);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else if (result.success) {
|
} else if ("success" in result && result.success) {
|
||||||
|
if (result.failed || ("deferred" in result && result.deferred))
|
||||||
|
toast.warning(
|
||||||
|
`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`,
|
||||||
|
);
|
||||||
|
else
|
||||||
toast.success(
|
toast.success(
|
||||||
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
|
`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -153,7 +169,11 @@ export function PlanView({
|
|||||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updateAssignmentAction(planId, date, valueToSet);
|
const result = await updateAssignmentAction(
|
||||||
|
planId,
|
||||||
|
date.slice(0, 10),
|
||||||
|
valueToSet,
|
||||||
|
);
|
||||||
if ("error" in result && result.error) {
|
if ("error" in result && result.error) {
|
||||||
toast.error(result.error as string);
|
toast.error(result.error as string);
|
||||||
} else {
|
} else {
|
||||||
@@ -162,7 +182,7 @@ export function PlanView({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!planId && respondedFamilies === 0) {
|
if (!planId && totalAvailabilitySlots === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
|
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
|
||||||
@@ -183,7 +203,7 @@ export function PlanView({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSendReminders}
|
onClick={handleSendReminders}
|
||||||
disabled={isPending}
|
disabled={isPending || !remindersOpen}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||||
@@ -211,7 +231,7 @@ export function PlanView({
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
||||||
{respondedFamilies} von {totalFamilies} Familien haben
|
{respondedFamilies} von {totalFamilies} Familien haben
|
||||||
geantwortet · noch {openFamilies} offen
|
vollständig geantwortet · noch {openFamilies} offen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
|
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
|
||||||
@@ -244,7 +264,10 @@ export function PlanView({
|
|||||||
{family.childCount === 1 ? "" : "er"}
|
{family.childCount === 1 ? "" : "er"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FamilyStatusPill selectedDays={family.selectedDays.length} />
|
<FamilyStatusPill
|
||||||
|
selectedDays={family.selectedDays.length}
|
||||||
|
requiredDays={family.requiredDays}
|
||||||
|
/>
|
||||||
<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">
|
<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.length > 0
|
||||||
? family.selectedDays.map((day) => day.label).join(" · ")
|
? family.selectedDays.map((day) => day.label).join(" · ")
|
||||||
@@ -258,7 +281,7 @@ export function PlanView({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSendReminders}
|
onClick={handleSendReminders}
|
||||||
disabled={isPending || openFamilies === 0}
|
disabled={isPending || openFamilies === 0 || !remindersOpen}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||||
@@ -314,8 +337,8 @@ export function PlanView({
|
|||||||
Plan automatisch generieren
|
Plan automatisch generieren
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||||
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
|
||||||
{respondedFamilies} Familien.
|
Familien.
|
||||||
</p>
|
</p>
|
||||||
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||||
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
||||||
@@ -408,6 +431,7 @@ export function PlanView({
|
|||||||
<ManualAssignmentTable
|
<ManualAssignmentTable
|
||||||
days={days}
|
days={days}
|
||||||
allChildren={allChildren}
|
allChildren={allChildren}
|
||||||
|
familyStatuses={familyStatuses}
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
onAssignmentChange={handleAssignmentChange}
|
onAssignmentChange={handleAssignmentChange}
|
||||||
@@ -447,7 +471,13 @@ export function PlanView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
|
function FamilyStatusPill({
|
||||||
|
selectedDays,
|
||||||
|
requiredDays,
|
||||||
|
}: {
|
||||||
|
selectedDays: number;
|
||||||
|
requiredDays: number;
|
||||||
|
}) {
|
||||||
if (selectedDays === 0) {
|
if (selectedDays === 0) {
|
||||||
return (
|
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)]">
|
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
||||||
@@ -457,8 +487,14 @@ function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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)]">
|
<span
|
||||||
{selectedDays} Tage
|
className={
|
||||||
|
selectedDays >= requiredDays
|
||||||
|
? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]"
|
||||||
|
: "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedDays} / {requiredDays} Tage
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -548,7 +584,11 @@ function PlanPreviewTable({
|
|||||||
{format(dateObj, "EEEE", { locale: de })}
|
{format(dateObj, "EEEE", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[var(--ink)]">
|
<div className="text-[var(--ink)]">
|
||||||
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
|
{assignedChild
|
||||||
|
? assignedChild.parentName
|
||||||
|
: day.childId
|
||||||
|
? "Inaktives Kind"
|
||||||
|
: "Unbesetzt"}
|
||||||
{assignedChild && (
|
{assignedChild && (
|
||||||
<span className="ml-1 text-[var(--ink-muted)]">
|
<span className="ml-1 text-[var(--ink-muted)]">
|
||||||
({assignedChild.name})
|
({assignedChild.name})
|
||||||
@@ -572,8 +612,20 @@ function PlanPreviewTable({
|
|||||||
</div>
|
</div>
|
||||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||||
<div>
|
<div>
|
||||||
<Badge variant={assignedChild ? "success" : "destructive"}>
|
<Badge
|
||||||
{assignedChild ? "Besetzt" : "Offen"}
|
variant={
|
||||||
|
day.isInvalid
|
||||||
|
? "warning"
|
||||||
|
: assignedChild
|
||||||
|
? "success"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{day.isInvalid
|
||||||
|
? "Bitte korrigieren"
|
||||||
|
: assignedChild
|
||||||
|
? "Besetzt"
|
||||||
|
: "Offen"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,17 +636,29 @@ function PlanPreviewTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManualAssignmentTable({
|
export function ManualAssignmentTable({
|
||||||
days,
|
days,
|
||||||
allChildren,
|
allChildren,
|
||||||
|
familyStatuses,
|
||||||
isDraft,
|
isDraft,
|
||||||
isPending,
|
isPending,
|
||||||
onAssignmentChange,
|
onAssignmentChange,
|
||||||
}: Pick<PlanViewProps, "days" | "allChildren"> & {
|
}: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onAssignmentChange: (date: string, newChildId: string) => void;
|
onAssignmentChange: (date: string, newChildId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const availabilityByFamily = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
familyStatuses.map((family) => [
|
||||||
|
family.id,
|
||||||
|
new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[familyStatuses],
|
||||||
|
);
|
||||||
|
|
||||||
// Map childId to familyId for quick lookup
|
// Map childId to familyId for quick lookup
|
||||||
const childFamilyMap = useMemo(() => {
|
const childFamilyMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -622,7 +686,12 @@ function ManualAssignmentTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden p-0">
|
<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 className="border-b border-[var(--hairline-soft)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
|
Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den
|
||||||
|
Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl
|
||||||
|
gilt für den gesamten Haushalt.
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm: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>Datum</div>
|
||||||
<div>Eingeteilte Familie</div>
|
<div>Eingeteilte Familie</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -630,17 +699,41 @@ function ManualAssignmentTable({
|
|||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||||
const dateObj = new Date(yr, mo - 1, dy);
|
const dateObj = new Date(yr, mo - 1, dy);
|
||||||
|
const dateKey = day.date.slice(0, 10);
|
||||||
|
const available = allChildren.filter((child) =>
|
||||||
|
availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||||
|
);
|
||||||
|
const unavailable = allChildren.filter(
|
||||||
|
(child) => !availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||||
|
);
|
||||||
|
const availableFamilies = new Set(
|
||||||
|
available.map((child) => child.familyId),
|
||||||
|
).size;
|
||||||
|
const selectedChild = allChildren.find(
|
||||||
|
(child) => child.id === day.childId,
|
||||||
|
);
|
||||||
|
const selectedAvailable =
|
||||||
|
!!selectedChild &&
|
||||||
|
availabilityByFamily.get(selectedChild.familyId)?.has(dateKey);
|
||||||
|
const option = (child: PlanViewProps["allChildren"][number]) => (
|
||||||
|
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||||
|
{child.parentName} ({child.name}) —{" "}
|
||||||
|
{familyAssignmentCounts.get(child.familyId) ?? 0}x
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.date}
|
key={day.date}
|
||||||
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
className="grid gap-3 sm:grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-[var(--ink)]">
|
<div className="font-medium text-[var(--ink)]">
|
||||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="w-full max-w-sm space-y-2">
|
||||||
<select
|
<select
|
||||||
|
aria-label={`Familie für ${format(dateObj, "dd.MM.yyyy")}`}
|
||||||
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)]"
|
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"}
|
value={day.childId || "empty"}
|
||||||
disabled={isPending || !!day.activeAlarmId}
|
disabled={isPending || !!day.activeAlarmId}
|
||||||
@@ -649,18 +742,40 @@ function ManualAssignmentTable({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="empty">-- Unbesetzt --</option>
|
<option value="empty">-- Unbesetzt --</option>
|
||||||
{allChildren.map((child) => {
|
{day.childId &&
|
||||||
const famId = child.familyId;
|
!allChildren.some((child) => child.id === day.childId) ? (
|
||||||
const count = famId
|
<option value={day.childId} disabled>
|
||||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
Inaktives Kind – bitte Einteilung entfernen
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<option key={child.id} value={child.id}>
|
|
||||||
{child.parentName} ({child.name}) — {count}x
|
|
||||||
</option>
|
</option>
|
||||||
);
|
) : null}
|
||||||
})}
|
{available.length > 0 ? (
|
||||||
|
<optgroup label="Verfügbar an diesem Tag">
|
||||||
|
{available.map(option)}
|
||||||
|
</optgroup>
|
||||||
|
) : null}
|
||||||
|
{unavailable.length > 0 ? (
|
||||||
|
<optgroup label="Keine Verfügbarkeit für diesen Tag">
|
||||||
|
{unavailable.map(option)}
|
||||||
|
</optgroup>
|
||||||
|
) : null}
|
||||||
</select>
|
</select>
|
||||||
|
{!day.isInvalid ? (
|
||||||
|
<p
|
||||||
|
className={`text-xs leading-5 ${(selectedChild && !selectedAvailable) || availableFamilies === 0 ? "text-[var(--warn)]" : "text-[var(--ink-soft)]"}`}
|
||||||
|
>
|
||||||
|
{selectedChild
|
||||||
|
? selectedAvailable
|
||||||
|
? "Eingeteilte Familie ist verfügbar."
|
||||||
|
: "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben."
|
||||||
|
: `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{day.isInvalid ? (
|
||||||
|
<span className="text-xs text-[var(--danger)]">
|
||||||
|
Einteilung nicht mehr gültig – bitte entfernen.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{!isDraft && (
|
{!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">
|
<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
|
Veröffentlicht
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import { UserRole } from "@prisma/client";
|
|||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { getTargetMonthData } from "@/lib/date-utils";
|
import { getTargetMonthData } from "@/lib/date-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
absenceDate,
|
||||||
|
absenceToday,
|
||||||
|
canManageKitaAbsences,
|
||||||
|
} from "@/lib/absence-policy";
|
||||||
import { Stat } from "@/components/ui/stat";
|
import { Stat } from "@/components/ui/stat";
|
||||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||||
import { NewsTicker } from "./news-ticker";
|
import { NewsTicker } from "./news-ticker";
|
||||||
@@ -30,8 +35,8 @@ export const metadata = { title: "Übersicht · Kita-Planer" };
|
|||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now);
|
const todayKey = absenceToday(now);
|
||||||
today.setHours(0, 0, 0, 0);
|
const today = absenceDate(todayKey);
|
||||||
|
|
||||||
const targetData = getTargetMonthData();
|
const targetData = getTargetMonthData();
|
||||||
const targetMonthStart = new Date(
|
const targetMonthStart = new Date(
|
||||||
@@ -138,6 +143,7 @@ export default async function DashboardPage() {
|
|||||||
note: true,
|
note: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
|
updatedAt: true,
|
||||||
child: {
|
child: {
|
||||||
select: {
|
select: {
|
||||||
firstName: true,
|
firstName: true,
|
||||||
@@ -172,6 +178,7 @@ export default async function DashboardPage() {
|
|||||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||||
<OnboardingDialog
|
<OnboardingDialog
|
||||||
user={{
|
user={{
|
||||||
|
id: session.user.id,
|
||||||
phone: onboardingUser.phone,
|
phone: onboardingUser.phone,
|
||||||
street: onboardingUser.street,
|
street: onboardingUser.street,
|
||||||
postalCode: onboardingUser.postalCode,
|
postalCode: onboardingUser.postalCode,
|
||||||
@@ -225,32 +232,27 @@ export default async function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
{canReportAbsences ? (
|
{canReportAbsences ? (
|
||||||
<AbsenceCard
|
<AbsenceCard
|
||||||
today={format(today, "yyyy-MM-dd")}
|
today={todayKey}
|
||||||
childOptions={absenceChildren.map((child) => ({
|
childOptions={absenceChildren.map((child) => ({
|
||||||
id: child.id,
|
id: child.id,
|
||||||
name: `${child.firstName} ${child.lastName}`,
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside>
|
<aside>
|
||||||
<UpcomingAbsencesCard
|
<UpcomingAbsencesCard
|
||||||
|
today={todayKey}
|
||||||
|
team={canManageKitaAbsences(session.user.role)}
|
||||||
absences={upcomingAbsences.map((absence) => ({
|
absences={upcomingAbsences.map((absence) => ({
|
||||||
id: absence.id,
|
id: absence.id,
|
||||||
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
childName: `${absence.child.firstName} ${absence.child.lastName}`,
|
||||||
reason: absence.reason,
|
reason: absence.reason,
|
||||||
note: absence.note,
|
note: absence.note,
|
||||||
startDate: format(absence.startDate, "yyyy-MM-dd"),
|
startDate: absence.startDate.toISOString().slice(0, 10),
|
||||||
endDate: format(absence.endDate, "yyyy-MM-dd"),
|
endDate: absence.endDate.toISOString().slice(0, 10),
|
||||||
|
updatedAt: absence.updatedAt.toISOString(),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useId, useState, useTransition, type ReactNode } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -32,6 +33,7 @@ export function Field({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
|
autoFocus,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -41,6 +43,7 @@ export function Field({
|
|||||||
min?: string | number;
|
min?: string | number;
|
||||||
max?: string | number;
|
max?: string | number;
|
||||||
step?: string | number;
|
step?: string | number;
|
||||||
|
autoFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +60,7 @@ export function Field({
|
|||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -123,7 +127,11 @@ function transform(command: string, form: FormData) {
|
|||||||
return {
|
return {
|
||||||
kind: text("kind"),
|
kind: text("kind"),
|
||||||
startDate: text("startDate"),
|
startDate: text("startDate"),
|
||||||
endDate: text("endDate"),
|
endDate:
|
||||||
|
text("kind") === "TIME_OFF" ||
|
||||||
|
(text("kind") === "VACATION" && form.get("halfDay") === "on")
|
||||||
|
? text("startDate")
|
||||||
|
: text("endDate"),
|
||||||
halfDay: form.get("halfDay") === "on",
|
halfDay: form.get("halfDay") === "on",
|
||||||
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
||||||
note: text("note"),
|
note: text("note"),
|
||||||
@@ -142,13 +150,19 @@ function StaffForm({
|
|||||||
children,
|
children,
|
||||||
label = "Speichern",
|
label = "Speichern",
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
anotherLabel,
|
||||||
}: {
|
}: {
|
||||||
command: string;
|
command: string;
|
||||||
profileId?: string;
|
profileId?: string;
|
||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
label?: string;
|
label?: string;
|
||||||
onSuccess?: (result: StaffActionResult) => void;
|
anotherLabel?: string;
|
||||||
|
onSuccess?: (
|
||||||
|
result: StaffActionResult,
|
||||||
|
form: FormData,
|
||||||
|
another: boolean,
|
||||||
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const [pending, start] = useTransition();
|
const [pending, start] = useTransition();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -159,6 +173,10 @@ function StaffForm({
|
|||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = new FormData(event.currentTarget);
|
const form = new FormData(event.currentTarget);
|
||||||
|
const another =
|
||||||
|
(event.nativeEvent as SubmitEvent).submitter?.getAttribute(
|
||||||
|
"value",
|
||||||
|
) === "another";
|
||||||
setError("");
|
setError("");
|
||||||
start(async () => {
|
start(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -173,7 +191,7 @@ function StaffForm({
|
|||||||
}
|
}
|
||||||
if (result.warning) toast.warning(result.warning);
|
if (result.warning) toast.warning(result.warning);
|
||||||
else toast.success(result.message ?? "Gespeichert.");
|
else toast.success(result.message ?? "Gespeichert.");
|
||||||
onSuccess?.(result);
|
onSuccess?.(result, form, another);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
@@ -187,10 +205,20 @@ function StaffForm({
|
|||||||
>
|
>
|
||||||
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
||||||
{children}
|
{children}
|
||||||
<div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
{pending ? "Wird gespeichert …" : label}
|
{pending ? "Wird gespeichert …" : label}
|
||||||
</Button>
|
</Button>
|
||||||
|
{anotherLabel ? (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="another"
|
||||||
|
>
|
||||||
|
{anotherLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -333,8 +361,12 @@ export function SetupForm({
|
|||||||
export function TimeForm({
|
export function TimeForm({
|
||||||
profileId,
|
profileId,
|
||||||
initial,
|
initial,
|
||||||
|
defaultDate,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
defaultDate?: string;
|
||||||
|
compact?: boolean;
|
||||||
initial?: {
|
initial?: {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -345,14 +377,34 @@ export function TimeForm({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [date, setDate] = useState(initial?.date ?? defaultDate ?? todayKey());
|
||||||
|
const [blockVersion, setBlockVersion] = useState(0);
|
||||||
|
const [continuation, setContinuation] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (next) {
|
||||||
|
setDate(initial?.date ?? defaultDate ?? todayKey());
|
||||||
|
setContinuation(false);
|
||||||
|
}
|
||||||
|
setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={initial ? "outline" : "default"}
|
variant={initial || compact ? "outline" : "default"}
|
||||||
size={initial ? "sm" : "default"}
|
size={initial || compact ? "sm" : "default"}
|
||||||
|
disabled={!initial && !!defaultDate && defaultDate > todayKey()}
|
||||||
|
aria-label={
|
||||||
|
compact ? `Arbeitszeit für ${defaultDate} erfassen` : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
|
{initial
|
||||||
|
? "Bearbeiten"
|
||||||
|
: compact
|
||||||
|
? "Zeit erfassen"
|
||||||
|
: "+ Arbeitszeit erfassen"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -366,31 +418,50 @@ export function TimeForm({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<StaffForm
|
<StaffForm
|
||||||
|
key={blockVersion}
|
||||||
command="time"
|
command="time"
|
||||||
profileId={profileId}
|
profileId={profileId}
|
||||||
extra={initial ? { id: initial.id } : {}}
|
extra={initial ? { id: initial.id } : {}}
|
||||||
label="Arbeitszeit speichern"
|
label="Arbeitszeit speichern"
|
||||||
onSuccess={() => setOpen(false)}
|
anotherLabel={
|
||||||
|
!initial ? "Speichern und weiteren Block erfassen" : undefined
|
||||||
|
}
|
||||||
|
onSuccess={(_result, form, another) => {
|
||||||
|
if (another && !initial) {
|
||||||
|
setDate(String(form.get("date")));
|
||||||
|
setContinuation(true);
|
||||||
|
setBlockVersion((version) => version + 1);
|
||||||
|
} else setOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
label="Datum"
|
label="Datum"
|
||||||
name="date"
|
name="date"
|
||||||
type="date"
|
type="date"
|
||||||
value={initial?.date ?? todayKey()}
|
value={date}
|
||||||
max={todayKey()}
|
max={todayKey()}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field
|
<Field
|
||||||
label="Beginn"
|
label="Beginn"
|
||||||
|
autoFocus={continuation}
|
||||||
name="start"
|
name="start"
|
||||||
type="time"
|
type="time"
|
||||||
value={minuteClock(initial?.startMinute ?? 480)}
|
value={
|
||||||
|
continuation
|
||||||
|
? undefined
|
||||||
|
: minuteClock(initial?.startMinute ?? 480)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Ende"
|
label="Ende"
|
||||||
name="end"
|
name="end"
|
||||||
type="time"
|
type="time"
|
||||||
value={minuteClock(initial?.endMinute ?? 960)}
|
value={
|
||||||
|
continuation
|
||||||
|
? undefined
|
||||||
|
: minuteClock(initial?.endMinute ?? 960)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@@ -399,7 +470,7 @@ export function TimeForm({
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
value={initial?.breakMinutes ?? 30}
|
value={continuation ? 0 : (initial?.breakMinutes ?? 30)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Notiz (optional)"
|
label="Notiz (optional)"
|
||||||
@@ -431,9 +502,24 @@ export function LeaveForm({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
||||||
|
const [startDate, setStartDate] = useState(initial?.startDate ?? todayKey());
|
||||||
|
const [endDate, setEndDate] = useState(initial?.endDate ?? todayKey());
|
||||||
|
const [halfDay, setHalfDay] = useState(initial?.halfDay ?? false);
|
||||||
|
const singleDay = kind === "TIME_OFF" || (kind === "VACATION" && halfDay);
|
||||||
const id = useId();
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (next) {
|
||||||
|
setKind(initial?.kind ?? "VACATION");
|
||||||
|
setStartDate(initial?.startDate ?? todayKey());
|
||||||
|
setEndDate(initial?.endDate ?? todayKey());
|
||||||
|
setHalfDay(initial?.halfDay ?? false);
|
||||||
|
}
|
||||||
|
setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size={initial ? "sm" : "default"} variant="outline">
|
<Button size={initial ? "sm" : "default"} variant="outline">
|
||||||
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
||||||
@@ -474,33 +560,51 @@ export function LeaveForm({
|
|||||||
{admin && <option value="SICKNESS">Krankheit</option>}
|
{admin && <option value="SICKNESS">Krankheit</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{kind === "VACATION" ? (
|
||||||
<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">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
name="halfDay"
|
name="halfDay"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked={initial?.halfDay}
|
checked={halfDay}
|
||||||
/>
|
onChange={(event) => setHalfDay(event.target.checked)}
|
||||||
Halber Urlaubstag (Von = Bis)
|
/>{" "}
|
||||||
|
Halber Urlaubstag
|
||||||
</label>
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className={singleDay ? "grid gap-4" : "grid grid-cols-2 gap-4"}>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor={`${id}-start`}>
|
||||||
|
{singleDay ? "Datum" : "Von"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-start`}
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(event) => setStartDate(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{singleDay ? (
|
||||||
|
<input type="hidden" name="endDate" value={startDate} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor={`${id}-end`}>Bis</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-end`}
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
|
required
|
||||||
|
min={startDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{kind === "TIME_OFF" && (
|
{kind === "TIME_OFF" && (
|
||||||
<Field
|
<Field
|
||||||
label="Freizeitausgleich in Stunden (Von = Bis)"
|
label="Freizeitausgleich in Stunden"
|
||||||
name="hours"
|
name="hours"
|
||||||
type="number"
|
type="number"
|
||||||
min={1 / 60}
|
min={1 / 60}
|
||||||
|
|||||||
@@ -284,7 +284,17 @@ export default async function PersonalPage({
|
|||||||
</div>
|
</div>
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<TimeForm profileId={profile.id} />
|
<TimeForm
|
||||||
|
key={`${profile.id}-${start}`}
|
||||||
|
profileId={profile.id}
|
||||||
|
defaultDate={
|
||||||
|
tab === "zeit"
|
||||||
|
? today >= start && today <= end
|
||||||
|
? today
|
||||||
|
: start
|
||||||
|
: today
|
||||||
|
}
|
||||||
|
/>
|
||||||
<LeaveForm profileId={profile.id} admin={admin} />
|
<LeaveForm profileId={profile.id} admin={admin} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -645,6 +655,7 @@ export default async function PersonalPage({
|
|||||||
"Ist",
|
"Ist",
|
||||||
"Urlaub / Krankheit",
|
"Urlaub / Krankheit",
|
||||||
"Differenz",
|
"Differenz",
|
||||||
|
"Erfassen",
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<th key={s} className="pb-3 pr-4 font-medium">
|
<th key={s} className="pb-3 pr-4 font-medium">
|
||||||
{s}
|
{s}
|
||||||
@@ -682,6 +693,15 @@ export default async function PersonalPage({
|
|||||||
>
|
>
|
||||||
{formatMinutes(d.balance)}
|
{formatMinutes(d.balance)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3 pl-3">
|
||||||
|
{editable && d.date <= today ? (
|
||||||
|
<TimeForm
|
||||||
|
profileId={profile.id}
|
||||||
|
defaultDate={d.date}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -696,6 +716,7 @@ export default async function PersonalPage({
|
|||||||
<td className="pt-4">
|
<td className="pt-4">
|
||||||
{formatMinutes(totals.balance)}
|
{formatMinutes(totals.balance)}
|
||||||
</td>
|
</td>
|
||||||
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import {
|
|||||||
parseDateInput,
|
parseDateInput,
|
||||||
} from "@/lib/child-birthdate";
|
} from "@/lib/child-birthdate";
|
||||||
import { serializableTransaction } from "@/lib/transaction";
|
import { serializableTransaction } from "@/lib/transaction";
|
||||||
|
import {
|
||||||
|
assertKitaAdminRemains,
|
||||||
|
lockKitaAccounts,
|
||||||
|
} from "@/lib/kita-admin-guard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
|
||||||
|
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||||
|
|
||||||
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
|
||||||
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
|
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
|
||||||
@@ -57,7 +63,11 @@ const childSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
const familySchema = z.object({
|
const familySchema = z.object({
|
||||||
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
|
familyName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Familienname ist erforderlich.")
|
||||||
|
.max(120)
|
||||||
|
.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const contactSchema = z.object({
|
const contactSchema = z.object({
|
||||||
@@ -86,10 +96,7 @@ const contactSchema = z.object({
|
|||||||
|
|
||||||
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
||||||
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
|
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
|
||||||
function firstZodMessage(
|
function firstZodMessage(error: z.ZodError, fallback: string): string {
|
||||||
error: z.ZodError,
|
|
||||||
fallback: string,
|
|
||||||
): string {
|
|
||||||
const issue = error.issues[0];
|
const issue = error.issues[0];
|
||||||
return issue?.message || fallback;
|
return issue?.message || fallback;
|
||||||
}
|
}
|
||||||
@@ -119,8 +126,13 @@ export async function createMyChild(rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await serializableTransaction(async tx => {
|
await serializableTransaction(async (tx) => {
|
||||||
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
|
const count = await tx.child.count({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId!,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (count >= 20) throw new Error("Household child limit reached");
|
if (count >= 20) throw new Error("Household child limit reached");
|
||||||
await tx.child.create({
|
await tx.child.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -164,10 +176,17 @@ export async function createMyFamily(rawPayload: unknown) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const attached = await tx.user.updateMany({
|
const attached = await tx.user.updateMany({
|
||||||
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
|
where: {
|
||||||
|
id: session.user.id,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: null,
|
||||||
|
disabledAt: null,
|
||||||
|
sessionVersion: session.user.sessionVersion,
|
||||||
|
},
|
||||||
data: { familyId: family.id },
|
data: { familyId: family.id },
|
||||||
});
|
});
|
||||||
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
|
if (attached.count !== 1)
|
||||||
|
throw new Error("Account already assigned or revoked");
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard/profil");
|
revalidatePath("/dashboard/profil");
|
||||||
@@ -176,7 +195,12 @@ export async function createMyFamily(rawPayload: unknown) {
|
|||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
|
if (
|
||||||
|
!(
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === "Account already assigned or revoked"
|
||||||
|
)
|
||||||
|
) {
|
||||||
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
|
||||||
}
|
}
|
||||||
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
return { error: "Der Haushalt konnte nicht angelegt werden." };
|
||||||
@@ -247,7 +271,11 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.child.update({
|
await prisma.child.update({
|
||||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
where: {
|
||||||
|
id: childId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId!,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
@@ -268,53 +296,68 @@ export async function deleteMyChild(childId: string) {
|
|||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const child = await requireOwnFamilyChild(
|
await serializableTransaction(async (tx) => {
|
||||||
childId,
|
if (!session.user.familyId)
|
||||||
session.user.familyId,
|
throw new UserFacingError(
|
||||||
session.user.kitaId,
|
"Dieses Kind gehört nicht zu deinem Haushalt.",
|
||||||
);
|
);
|
||||||
|
const child = await tx.child.findFirst({
|
||||||
if (!child) {
|
where: {
|
||||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
id: childId,
|
||||||
}
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId,
|
||||||
await prisma.child.delete({
|
},
|
||||||
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!child)
|
||||||
|
throw new UserFacingError(
|
||||||
|
"Dieses Kind gehört nicht zu deinem Haushalt.",
|
||||||
|
);
|
||||||
|
await assertChildrenRemovable(
|
||||||
|
tx,
|
||||||
|
session.user.kitaId,
|
||||||
|
session.user.familyId,
|
||||||
|
[childId],
|
||||||
|
);
|
||||||
|
await tx.child.delete({
|
||||||
|
where: {
|
||||||
|
id: childId,
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
familyId: session.user.familyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard/profil");
|
revalidatePath("/dashboard/profil");
|
||||||
revalidatePath("/dashboard/families");
|
revalidatePath("/dashboard/families");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!(error instanceof UserFacingError))
|
||||||
console.error("Fehler beim Entfernen des Kindes:", error);
|
console.error("Fehler beim Entfernen des Kindes:", error);
|
||||||
return { error: "Das Kind konnte nicht entfernt werden." };
|
return {
|
||||||
|
error: publicActionError(error, "Das Kind konnte nicht entfernt werden."),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMyAccount() {
|
export async function deleteMyAccount(): Promise<{
|
||||||
|
success?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const session = await requireKitaSession();
|
const session = await requireKitaSession();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findFirst({
|
return await serializableTransaction(async (tx) => {
|
||||||
where: {
|
await lockKitaAccounts(tx, session.user.kitaId);
|
||||||
id: session.user.id,
|
const user = await tx.user.findFirst({
|
||||||
kitaId: session.user.kitaId,
|
where: { id: session.user.id, kitaId: session.user.kitaId },
|
||||||
},
|
select: { id: true, familyId: true },
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
familyId: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!user) throw new UserFacingError("Account wurde nicht gefunden.");
|
||||||
if (!user) {
|
await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]);
|
||||||
return { error: "Account wurde nicht gefunden." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const assignments = user.familyId
|
||||||
const activeNotdienstAssignments = user.familyId
|
? await tx.notdienstAssignment.count({
|
||||||
? await prisma.notdienstAssignment.count({
|
|
||||||
where: {
|
where: {
|
||||||
kitaId: session.user.kitaId,
|
kitaId: session.user.kitaId,
|
||||||
date: { gte: today },
|
date: { gte: today },
|
||||||
@@ -323,52 +366,33 @@ export async function deleteMyAccount() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
: 0;
|
: 0;
|
||||||
|
if (assignments)
|
||||||
if (activeNotdienstAssignments > 0) {
|
throw new UserFacingError(
|
||||||
return {
|
|
||||||
error:
|
|
||||||
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
|
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
|
||||||
|
);
|
||||||
|
if (user.familyId) {
|
||||||
|
const family = await tx.family.findFirst({
|
||||||
|
where: { id: user.familyId, kitaId: session.user.kitaId },
|
||||||
|
select: { id: true, users: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
if (!family)
|
||||||
|
throw new UserFacingError("Haushalt wurde nicht gefunden.");
|
||||||
|
if (family.users.length <= 1) {
|
||||||
|
await tx.family.delete({ where: { id: family.id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.user.delete({ where: { id: user.id } });
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UserFacingError))
|
||||||
|
console.error("Fehler beim Löschen des Accounts:", error);
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Ein Fehler ist beim Löschen aufgetreten.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.familyId) {
|
|
||||||
await prisma.user.delete({
|
|
||||||
where: { id: user.id },
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const family = await prisma.family.findFirst({
|
|
||||||
where: {
|
|
||||||
id: user.familyId,
|
|
||||||
kitaId: session.user.kitaId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
users: {
|
|
||||||
select: { id: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!family) {
|
|
||||||
return { error: "Haushalt wurde nicht gefunden." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (family.users.length <= 1) {
|
|
||||||
await prisma.family.delete({
|
|
||||||
where: { id: family.id },
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.delete({
|
|
||||||
where: { id: user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Fehler beim Löschen des Accounts:", error);
|
|
||||||
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
|||||||
import { MyChildrenManager } from "./_components/my-children-manager";
|
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||||
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||||
import { ContactForm } from "./_components/contact-form";
|
import { ContactForm } from "./_components/contact-form";
|
||||||
|
import { PasswordForm } from "../../passwort-vergessen/password-form";
|
||||||
import { StaffEmailSettings } from "../personal/forms";
|
import { StaffEmailSettings } from "../personal/forms";
|
||||||
|
|
||||||
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
||||||
@@ -56,6 +57,7 @@ export default async function ProfilPage() {
|
|||||||
city: true,
|
city: true,
|
||||||
directoryOptInAt: true,
|
directoryOptInAt: true,
|
||||||
staffDecisionEmails: true,
|
staffDecisionEmails: true,
|
||||||
|
staffProfile: { select: { id: true } },
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
family: {
|
family: {
|
||||||
select: {
|
select: {
|
||||||
@@ -210,7 +212,19 @@ export default async function ProfilPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{user.role === UserRole.ERZIEHER && (
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Passwort ändern</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Verwalte dein persönliches Anmeldepasswort.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PasswordForm mode="change" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{user.staffProfile && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>E-Mail-Hinweise</CardTitle>
|
<CardTitle>E-Mail-Hinweise</CardTitle>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
|
import { changeKitaRole } from "@/lib/kita-role-management";
|
||||||
|
import { publicActionError, UserFacingError } from "@/lib/action-error";
|
||||||
|
|
||||||
|
export async function changeKitaRoleAction(
|
||||||
|
raw: unknown,
|
||||||
|
): Promise<{
|
||||||
|
success?: boolean;
|
||||||
|
error?: string;
|
||||||
|
changed?: boolean;
|
||||||
|
ownRoleChanged?: boolean;
|
||||||
|
}> {
|
||||||
|
const { user } = await requireRole([UserRole.ADMIN]);
|
||||||
|
if (!user.kitaId) return { error: "Keine Kita zugeordnet." };
|
||||||
|
try {
|
||||||
|
const result = await changeKitaRole(
|
||||||
|
{ id: user.id, kitaId: user.kitaId, sessionVersion: user.sessionVersion },
|
||||||
|
raw,
|
||||||
|
);
|
||||||
|
revalidatePath("/dashboard", "layout");
|
||||||
|
revalidatePath("/admin");
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UserFacingError))
|
||||||
|
console.error("Rolle konnte nicht geändert werden:", error);
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Rolle konnte nicht geändert werden. Bitte erneut versuchen.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
import { requireRole } from "@/lib/auth-utils";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getKitaRoleHistory } from "@/lib/kita-role-management";
|
||||||
|
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { RoleList } from "./role-list";
|
||||||
|
|
||||||
|
export const metadata = { title: "Zugriffsrechte · Kita-Planer" };
|
||||||
|
export default async function KitaRolesPage() {
|
||||||
|
const { user } = await requireRole([UserRole.ADMIN]);
|
||||||
|
const kitaId = user.kitaId!;
|
||||||
|
const [people, history] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { kitaId, role: { not: UserRole.SUPERADMIN } },
|
||||||
|
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
familyId: true,
|
||||||
|
disabledAt: true,
|
||||||
|
lockedUntil: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
|
family: { select: { name: true } },
|
||||||
|
staffProfile: { select: { id: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getKitaRoleHistory(kitaId),
|
||||||
|
]);
|
||||||
|
const now = new Date();
|
||||||
|
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)]">
|
||||||
|
VERWALTUNG · KITA-KONTEN
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||||
|
Zugriffsrechte{" "}
|
||||||
|
<em className="font-normal text-[var(--accent-deep)]">verwalten</em>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||||
|
Ernenne weitere Kita-Admins oder die Notdienst-Koordination. Rollen
|
||||||
|
gelten sofort; offene Einladungen müssen weiterhin aktiviert werden.
|
||||||
|
Mindestens ein aktiver Kita-Admin bleibt erhalten.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<RoleList
|
||||||
|
currentUserId={user.id}
|
||||||
|
people={people.map((person) => ({
|
||||||
|
id: person.id,
|
||||||
|
name: `${person.firstName} ${person.lastName}`,
|
||||||
|
email: person.email,
|
||||||
|
role: person.role,
|
||||||
|
familyId: person.familyId,
|
||||||
|
familyName: person.family?.name ?? null,
|
||||||
|
hasStaffProfile: !!person.staffProfile,
|
||||||
|
status: person.disabledAt
|
||||||
|
? "Konto deaktiviert"
|
||||||
|
: person.lockedUntil && person.lockedUntil > now
|
||||||
|
? "Anmeldung gesperrt"
|
||||||
|
: !person.emailVerifiedAt
|
||||||
|
? "Einladung offen"
|
||||||
|
: "Zugang aktiviert",
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Card className="mt-7 p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--ink)]">
|
||||||
|
Letzte Rollenänderungen
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-[var(--ink-soft)]">
|
||||||
|
Ämter und Zuständigkeiten verwaltest du weiterhin bei den Familien.
|
||||||
|
Sie verändern die Zugriffsrechte nicht.
|
||||||
|
</p>
|
||||||
|
{history.length ? (
|
||||||
|
<ul className="mt-4 divide-y divide-[var(--hairline-soft)]">
|
||||||
|
{history.map((entry) => {
|
||||||
|
const data = entry.metadata as {
|
||||||
|
previousRole?: UserRole;
|
||||||
|
newRole?: UserRole;
|
||||||
|
} | null;
|
||||||
|
return (
|
||||||
|
<li key={entry.id} className="py-3 text-sm leading-6">
|
||||||
|
<p className="font-medium text-[var(--ink)]">
|
||||||
|
{entry.targetLabel}:{" "}
|
||||||
|
{data?.previousRole
|
||||||
|
? kitaRoleLabels[data.previousRole]
|
||||||
|
: "—"}{" "}
|
||||||
|
→ {data?.newRole ? kitaRoleLabels[data.newRole] : "—"}
|
||||||
|
</p>
|
||||||
|
<p className="break-words text-xs text-[var(--ink-muted)]">
|
||||||
|
{new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
}).format(entry.createdAt)}{" "}
|
||||||
|
· {entry.actorEmail}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm text-[var(--ink-muted)]">
|
||||||
|
Noch keine Rollenänderungen protokolliert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { UserRole } from "@prisma/client";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { availableKitaRoles, kitaRoleLabels } from "@/lib/kita-roles";
|
||||||
|
import { changeKitaRoleAction } from "./actions";
|
||||||
|
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
ELTERN: "Eigener Haushalt, Verfügbarkeiten, Diensttausch und Kita-Alltag.",
|
||||||
|
KOORDINATOR: "Zusätzlich Notdienstpläne, Alarme und Termine koordinieren.",
|
||||||
|
ADMIN:
|
||||||
|
"Familien, Team, Zugriffsrechte und alle Kita-Verwaltungsbereiche verwalten.",
|
||||||
|
ERZIEHER:
|
||||||
|
"Personalbereich, eigene Arbeitszeit und Anträge sowie Kinder-Abwesenheiten.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RolePerson = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
familyId: string | null;
|
||||||
|
hasStaffProfile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleChangeButton({
|
||||||
|
person,
|
||||||
|
ownAccount = false,
|
||||||
|
}: {
|
||||||
|
person: RolePerson;
|
||||||
|
ownAccount?: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [role, setRole] = useState(person.role);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const roles = availableKitaRoles(person);
|
||||||
|
const submit = () => {
|
||||||
|
setError("");
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await changeKitaRoleAction({
|
||||||
|
userId: person.id,
|
||||||
|
role,
|
||||||
|
expectedRole: person.role,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Zugriffsrolle gespeichert.");
|
||||||
|
setOpen(false);
|
||||||
|
if (result.ownRoleChanged) router.replace("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError(
|
||||||
|
"Die Änderung konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Zugriffsrechte für ${person.name}`}
|
||||||
|
disabled={person.role === "SUPERADMIN"}
|
||||||
|
onClick={() => {
|
||||||
|
setRole(person.role);
|
||||||
|
setError("");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" /> Zugriffsrechte
|
||||||
|
</Button>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!pending) setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Zugriffsrechte für {person.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Die Zugriffsrolle bestimmt, welche Bereiche diese Person nutzen
|
||||||
|
und verwalten kann. Ämter wie „Vorstand“ werden separat vergeben.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<fieldset disabled={pending} className="grid gap-2">
|
||||||
|
<legend className="mb-2 text-sm font-semibold text-[var(--ink)]">
|
||||||
|
Zugriffsrolle
|
||||||
|
</legend>
|
||||||
|
{roles.map((value) => (
|
||||||
|
<label
|
||||||
|
key={value}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors ${role === value ? "border-[var(--accent-mid)] bg-[var(--accent-soft)]" : "border-[var(--hairline)] hover:bg-[var(--surface-2)]"}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`role-${person.id}`}
|
||||||
|
value={value}
|
||||||
|
checked={role === value}
|
||||||
|
onChange={() => setRole(value)}
|
||||||
|
className="mt-1 accent-[var(--accent-deep)]"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-[var(--ink)]">
|
||||||
|
{kitaRoleLabels[value]}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
{descriptions[value]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
{person.hasStaffProfile
|
||||||
|
? "Das Personalprofil mit Arbeitszeiten und Anträgen bleibt erhalten. Mit Kita-Admin-Rechten ist zusätzlich die Personalverwaltung zugänglich."
|
||||||
|
: person.familyId
|
||||||
|
? "Haushalt, Kinder und eigene Dienste bleiben erhalten."
|
||||||
|
: "Dieses Konto hat keinen eigenen Haushalt. Deshalb steht die Elternrolle nicht zur Auswahl."}
|
||||||
|
</p>
|
||||||
|
{ownAccount && role !== person.role ? (
|
||||||
|
<p className="rounded-lg bg-[var(--warn-soft)] p-3 text-sm leading-5 text-[var(--warn)]">
|
||||||
|
Du änderst deine eigenen Rechte. Nach dem Speichern gelangst du
|
||||||
|
zur Übersicht; der Zugang zur Verwaltung richtet sich dann nach
|
||||||
|
der neuen Rolle.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm leading-5 text-[var(--danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button disabled={pending || role === person.role} onClick={submit}>
|
||||||
|
{pending ? "Wird gespeichert …" : "Rolle speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { RoleBadge } from "@/components/ui/role-badge";
|
||||||
|
import { kitaRoleLabels } from "@/lib/kita-roles";
|
||||||
|
import { RoleChangeButton, type RolePerson } from "./role-change-button";
|
||||||
|
|
||||||
|
type Person = RolePerson & {
|
||||||
|
email: string;
|
||||||
|
familyName: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
export function RoleList({
|
||||||
|
people,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
people: Person[];
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const search = query.trim().toLocaleLowerCase("de");
|
||||||
|
const filtered = people.filter((person) =>
|
||||||
|
`${person.name} ${person.email} ${person.familyName ?? ""} ${kitaRoleLabels[person.role]}`
|
||||||
|
.toLocaleLowerCase("de")
|
||||||
|
.includes(search),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
aria-label="Kita-Konten durchsuchen"
|
||||||
|
placeholder="Name, Haushalt oder Rolle suchen …"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[var(--ink-muted)]">
|
||||||
|
{filtered.length} {filtered.length === 1 ? "Konto" : "Konten"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
<div className="hidden gap-6 border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] md:grid md:grid-cols-[minmax(0,1fr)_220px_160px]">
|
||||||
|
<span>Person & Zuordnung</span>
|
||||||
|
<span>Zugriffsrolle</span>
|
||||||
|
<span className="text-right">Verwalten</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||||
|
{filtered.map((person) => (
|
||||||
|
<div
|
||||||
|
key={person.id}
|
||||||
|
className="grid items-center gap-4 px-5 py-5 md:grid-cols-[minmax(0,1fr)_220px_160px] md:gap-6"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-[var(--ink)]">
|
||||||
|
{person.name}
|
||||||
|
{person.id === currentUserId ? (
|
||||||
|
<span className="ml-2 text-xs font-normal text-[var(--ink-muted)]">
|
||||||
|
Du
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-words text-sm text-[var(--ink-soft)]">
|
||||||
|
{person.email}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||||
|
{[
|
||||||
|
person.familyName
|
||||||
|
? `Haushalt ${person.familyName}`
|
||||||
|
: "Kein eigener Haushalt",
|
||||||
|
person.hasStaffProfile ? "Personalprofil vorhanden" : null,
|
||||||
|
person.status,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RoleBadge role={person.role} />
|
||||||
|
<div className="md:justify-self-end">
|
||||||
|
<RoleChangeButton
|
||||||
|
person={person}
|
||||||
|
ownAccount={person.id === currentUserId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!filtered.length ? (
|
||||||
|
<p className="p-8 text-center text-sm text-[var(--ink-soft)]">
|
||||||
|
Keine passenden Kita-Konten gefunden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import { publicPageMetadata } from "@/lib/marketing/seo";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = publicPageMetadata(
|
||||||
title: "Datenschutz | Kita-Planer",
|
"/datenschutz",
|
||||||
description: "Datenschutzhinweise und Platzhalter für Kita-Planer.",
|
"Datenschutz",
|
||||||
};
|
"Datenschutzhinweise zu Konten, Kontaktfreigaben und der Nutzung von Kita-Planer.",
|
||||||
|
);
|
||||||
|
|
||||||
const overviewItems = [
|
const overviewItems = [
|
||||||
{
|
{
|
||||||
@@ -77,9 +78,7 @@ export default function DatenschutzPage() {
|
|||||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-semibold tracking-tight">
|
<h2 className="font-semibold tracking-tight">{item.title}</h2>
|
||||||
{item.title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
{item.text}
|
{item.text}
|
||||||
</p>
|
</p>
|
||||||
@@ -154,9 +153,9 @@ export default function DatenschutzPage() {
|
|||||||
<p className="mt-3 leading-7 text-slate-700">
|
<p className="mt-3 leading-7 text-slate-700">
|
||||||
Zugriff auf Daten erhalten nur berechtigte Nutzerinnen und Nutzer
|
Zugriff auf Daten erhalten nur berechtigte Nutzerinnen und Nutzer
|
||||||
innerhalb der jeweiligen Kita beziehungsweise des jeweiligen
|
innerhalb der jeweiligen Kita beziehungsweise des jeweiligen
|
||||||
Elternvereins. Für Hosting, E-Mail-Versand oder technische
|
Elternvereins. Für Hosting, E-Mail-Versand oder technische Dienste
|
||||||
Dienste können Auftragsverarbeiter eingesetzt werden:
|
können Auftragsverarbeiter eingesetzt werden: [Hosting-Anbieter],
|
||||||
[Hosting-Anbieter], [E-Mail-Anbieter], [weitere Dienstleister].
|
[E-Mail-Anbieter], [weitere Dienstleister].
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,9 +179,9 @@ export default function DatenschutzPage() {
|
|||||||
<p className="mt-3 leading-7 text-slate-700">
|
<p className="mt-3 leading-7 text-slate-700">
|
||||||
Kita-Planer verwendet technisch notwendige Cookies oder
|
Kita-Planer verwendet technisch notwendige Cookies oder
|
||||||
vergleichbare Technologien, um Login-Sitzungen sicher
|
vergleichbare Technologien, um Login-Sitzungen sicher
|
||||||
bereitzustellen. Für die öffentliche Website kann zusätzlich
|
bereitzustellen. Für die öffentliche Website kann zusätzlich Umami
|
||||||
Umami Analytics eingesetzt werden, um Seitenaufrufe anonymisiert
|
Analytics eingesetzt werden, um Seitenaufrufe anonymisiert und
|
||||||
und ohne Cookies auszuwerten.
|
ohne Cookies auszuwerten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,9 +219,9 @@ export default function DatenschutzPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 leading-7 text-slate-700">
|
<p className="mt-3 leading-7 text-slate-700">
|
||||||
Betroffene Personen können sich bei einer
|
Betroffene Personen können sich bei einer
|
||||||
Datenschutzaufsichtsbehörde beschweren. Zuständig ist
|
Datenschutzaufsichtsbehörde beschweren. Zuständig ist insbesondere
|
||||||
insbesondere die Aufsichtsbehörde am Sitz der verantwortlichen
|
die Aufsichtsbehörde am Sitz der verantwortlichen Stelle:
|
||||||
Stelle: [zuständige Datenschutzaufsichtsbehörde].
|
[zuständige Datenschutzaufsichtsbehörde].
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { ScreenshotPreview } from "@/components/marketing/product-screenshots";
|
||||||
|
import { getProductScreenshot } from "@/lib/marketing/screenshots";
|
||||||
|
import { StructuredData } from "@/components/marketing/structured-data";
|
||||||
|
import { featureStructuredData, publicPageMetadata } from "@/lib/marketing/seo";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -48,30 +52,11 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return publicPageMetadata(
|
||||||
title: feature.seoTitle,
|
`/features/${feature.slug}`,
|
||||||
description: feature.seoDescription,
|
feature.seoTitle,
|
||||||
openGraph: {
|
feature.seoDescription,
|
||||||
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) {
|
export default async function FeaturePage({ params }: FeaturePageProps) {
|
||||||
@@ -83,25 +68,34 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Icon = feature.icon;
|
const Icon = feature.icon;
|
||||||
|
const screenshot = getProductScreenshot(slug);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
|
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
|
||||||
|
<StructuredData data={featureStructuredData(feature)} />
|
||||||
<header className="border-b border-slate-200 bg-white/95">
|
<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">
|
<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">
|
<Link
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-950 text-white">
|
href="/"
|
||||||
|
className="flex shrink-0 items-center gap-2 text-slate-950 sm:gap-3"
|
||||||
|
>
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-950 text-white sm:h-9 sm:w-9">
|
||||||
<ShieldCheck className="h-5 w-5" />
|
<ShieldCheck className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base font-semibold tracking-tight">
|
<span className="whitespace-nowrap text-sm font-semibold tracking-tight sm:text-base">
|
||||||
Kita-Planer
|
Kita-Planer
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-2">
|
<nav className="flex items-center gap-1 sm:gap-2">
|
||||||
<Button asChild variant="ghost">
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
className="px-3 text-xs sm:px-4 sm:text-sm"
|
||||||
|
>
|
||||||
<Link href="/login">Login</Link>
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild className="px-3 text-xs sm:px-4 sm:text-sm">
|
||||||
<Link href="/#kontakt">Demo anfragen</Link>
|
<Link href="/#kontakt">Demo anfragen</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -178,10 +172,17 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{screenshot && (
|
||||||
|
<section aria-label={`${feature.shortTitle} in der App`} className="mx-auto w-full max-w-7xl px-5 pt-16 sm:px-6">
|
||||||
|
<h2 className="mb-6 text-2xl font-semibold tracking-tight">Ein Blick in die Anwendung</h2>
|
||||||
|
<ScreenshotPreview screenshot={screenshot} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="mx-auto grid w-full max-w-7xl gap-6 px-5 py-20 sm:px-6 lg:grid-cols-2">
|
<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">
|
<Card className="border-slate-200 bg-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl tracking-tight">
|
<CardTitle as="h2" className="text-2xl tracking-tight">
|
||||||
{feature.problemTitle}
|
{feature.problemTitle}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base leading-7 text-slate-600">
|
<CardDescription className="text-base leading-7 text-slate-600">
|
||||||
@@ -192,7 +193,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
|
|||||||
|
|
||||||
<Card className="border-slate-200 bg-white">
|
<Card className="border-slate-200 bg-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl tracking-tight">
|
<CardTitle as="h2" className="text-2xl tracking-tight">
|
||||||
{feature.solutionTitle}
|
{feature.solutionTitle}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base leading-7 text-slate-600">
|
<CardDescription className="text-base leading-7 text-slate-600">
|
||||||
@@ -220,7 +221,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
|
|||||||
<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">
|
<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" />
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl tracking-tight">
|
<CardTitle as="h3" className="text-xl tracking-tight">
|
||||||
{benefit.title}
|
{benefit.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm leading-6 text-slate-600">
|
<CardDescription className="text-sm leading-6 text-slate-600">
|
||||||
@@ -273,7 +274,7 @@ export default async function FeaturePage({ params }: FeaturePageProps) {
|
|||||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
|
<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" />
|
<HelpCircle className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg leading-snug">
|
<CardTitle as="h3" className="text-lg leading-snug">
|
||||||
{faq.question}
|
{faq.question}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm leading-6 text-slate-600">
|
<CardDescription className="text-sm leading-6 text-slate-600">
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import { publicPageMetadata } from "@/lib/marketing/seo";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, Building2, Mail, MapPin, ShieldCheck } from "lucide-react";
|
import { ArrowLeft, Building2, Mail, MapPin, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = publicPageMetadata(
|
||||||
title: "Impressum | Kita-Planer",
|
"/impressum",
|
||||||
description: "Impressum und Anbieterkennzeichnung von Kita-Planer.",
|
"Impressum",
|
||||||
};
|
"Impressum und Anbieterkennzeichnung von Kita-Planer.",
|
||||||
|
);
|
||||||
|
|
||||||
const contactItems = [
|
const contactItems = [
|
||||||
{
|
{
|
||||||
@@ -117,8 +118,8 @@ export default function ImpressumPage() {
|
|||||||
Umsatzsteuer-ID
|
Umsatzsteuer-ID
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 leading-7 text-slate-700">
|
<p className="mt-3 leading-7 text-slate-700">
|
||||||
Umsatzsteuer-Identifikationsnummer gemäß § 27a
|
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:
|
||||||
Umsatzsteuergesetz: -
|
-
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,9 +127,7 @@ export default function ImpressumPage() {
|
|||||||
<h2 className="text-xl font-semibold tracking-tight">
|
<h2 className="text-xl font-semibold tracking-tight">
|
||||||
Verantwortlich für den Inhalt
|
Verantwortlich für den Inhalt
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 leading-7 text-slate-700">
|
<p className="mt-3 leading-7 text-slate-700">Torben Indorf</p>
|
||||||
Torben Indorf
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export async function acceptInviteAction(
|
|||||||
sessionVersion: { increment: 1 } },
|
sessionVersion: { increment: 1 } },
|
||||||
});
|
});
|
||||||
if (activated.count !== 1) throw new Error("INVITE_USED");
|
if (activated.count !== 1) throw new Error("INVITE_USED");
|
||||||
|
await tx.invitation.updateMany({
|
||||||
|
where: { token, userId },
|
||||||
|
data: { status: "ACCEPTED", acceptedAt: now, leaseUntil: null },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "INVITE_USED") return { attempt, values, errors: { _form: ["Dieser Link kann nicht mehr verwendet werden."] } };
|
if (error instanceof Error && error.message === "INVITE_USED") return { attempt, values, errors: { _form: ["Dieser Link kann nicht mehr verwendet werden."] } };
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { getSession } from "@/lib/auth-utils";
|
import { getSession } from "@/lib/auth-utils";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import {
|
||||||
|
getPublicSiteOrigin,
|
||||||
|
marketingDescription,
|
||||||
|
marketingTitle,
|
||||||
|
socialImage,
|
||||||
|
} from "@/lib/marketing/seo";
|
||||||
import { Manrope } from "next/font/google";
|
import { Manrope } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
@@ -13,36 +19,24 @@ const manrope = Manrope({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(
|
metadataBase: new URL(getPublicSiteOrigin()),
|
||||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de",
|
title: { default: marketingTitle, template: "%s | Kita-Planer" },
|
||||||
),
|
description: marketingDescription,
|
||||||
title: {
|
// Public marketing pages opt in explicitly; account and internal pages stay out.
|
||||||
default: "Der digitale Kita-Planer für Elternvereine",
|
robots: { index: false, follow: false },
|
||||||
template: "%s | Kita-Planer",
|
|
||||||
},
|
|
||||||
description:
|
|
||||||
"Kita-Planer bündelt Dienste, Termine, Notdienste, Abwesenheiten und sichere Kommunikation für Elternvereine an einem Ort.",
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Der digitale Kita-Planer für Elternvereine",
|
title: marketingTitle,
|
||||||
description:
|
description: marketingDescription,
|
||||||
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
|
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "de_DE",
|
locale: "de_DE",
|
||||||
images: [
|
siteName: "Kita-Planer",
|
||||||
{
|
images: [socialImage],
|
||||||
url: "/og-image.png",
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: "Ein Kind spielt mit bunten Holzklötzen.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Der digitale Kita-Planer für Elternvereine",
|
title: marketingTitle,
|
||||||
description:
|
description: marketingDescription,
|
||||||
"Organisiert Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam im Blick.",
|
images: [{ url: socialImage.url, alt: socialImage.alt }],
|
||||||
images: ["/og-image.png"],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,13 +45,13 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const [requestHeaders, session] = await Promise.all([headers(), getSession()]);
|
const [requestHeaders, session] = await Promise.all([
|
||||||
|
headers(),
|
||||||
|
getSession(),
|
||||||
|
]);
|
||||||
const nonce = requestHeaders.get("x-nonce") ?? undefined;
|
const nonce = requestHeaders.get("x-nonce") ?? undefined;
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="de" className={`${manrope.variable} h-full antialiased`}>
|
||||||
lang="de"
|
|
||||||
className={`${manrope.variable} h-full antialiased`}
|
|
||||||
>
|
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
{children}
|
{children}
|
||||||
<UmamiAnalytics nonce={nonce} enabled={!session?.user?.id} />
|
<UmamiAnalytics nonce={nonce} enabled={!session?.user?.id} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,7 +24,10 @@ export function LoginForm({
|
|||||||
devParentEmail?: string;
|
devParentEmail?: string;
|
||||||
devParentPassword?: string;
|
devParentPassword?: string;
|
||||||
}) {
|
}) {
|
||||||
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
const [state, formAction, pending] = useActionState(
|
||||||
|
loginAction,
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
@@ -67,6 +71,13 @@ export function LoginForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/passwort-vergessen"
|
||||||
|
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</Link>
|
||||||
|
|
||||||
{state.errors?._form?.[0] && (
|
{state.errors?._form?.[0] && (
|
||||||
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
{state.errors._form[0]}
|
{state.errors._form[0]}
|
||||||
@@ -79,7 +90,9 @@ export function LoginForm({
|
|||||||
|
|
||||||
{showDevHelpers && (
|
{showDevHelpers && (
|
||||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
|
||||||
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
|
<p className="text-center font-medium text-muted-foreground">
|
||||||
|
Dev-Schnellzugriff (nur lokal):
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{devAdminEmail && (
|
{devAdminEmail && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { LoginForm } from "./login-form";
|
|||||||
export const metadata = { title: "Anmelden · Kita-Planer" };
|
export const metadata = { title: "Anmelden · Kita-Planer" };
|
||||||
|
|
||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
searchParams?: Promise<{ callbackUrl?: string | string[]; reauth?: string }>;
|
searchParams?: Promise<{
|
||||||
|
callbackUrl?: string | string[];
|
||||||
|
reauth?: string;
|
||||||
|
passwordChanged?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
@@ -31,9 +35,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
||||||
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
|
const devAdminPassword = isDev
|
||||||
|
? process.env.DEV_LOGIN_ADMIN_PASSWORD
|
||||||
|
: undefined;
|
||||||
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
|
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
|
||||||
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
|
const devParentPassword = isDev
|
||||||
|
? process.env.DEV_LOGIN_PARENT_PASSWORD
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
||||||
@@ -43,6 +51,15 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
<CardDescription>Willkommen zurück.</CardDescription>
|
<CardDescription>Willkommen zurück.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{resolvedSearchParams?.passwordChanged === "1" ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="rounded-lg bg-[var(--good-soft)] p-3 text-sm text-[var(--ink)]"
|
||||||
|
>
|
||||||
|
Dein Passwort wurde geändert. Melde dich mit dem neuen Passwort
|
||||||
|
an.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<LoginForm
|
<LoginForm
|
||||||
callbackUrl={callbackUrl}
|
callbackUrl={callbackUrl}
|
||||||
devAdminEmail={devAdminEmail}
|
devAdminEmail={devAdminEmail}
|
||||||
@@ -52,7 +69,10 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Noch keinen Account?{" "}
|
Noch keinen Account?{" "}
|
||||||
<Link href="/register" className="font-medium text-foreground underline">
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="font-medium text-foreground underline"
|
||||||
|
>
|
||||||
Kita registrieren
|
Kita registrieren
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { isPasswordResetValid } from "@/lib/password-management";
|
||||||
|
import { PasswordForm } from "../../passwort-vergessen/password-form";
|
||||||
|
export const metadata = {
|
||||||
|
title: "Neues Passwort · Kita-Planer",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
export default async function ResetPasswordPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ token: string }>;
|
||||||
|
}) {
|
||||||
|
const { token } = await params;
|
||||||
|
const valid = await isPasswordResetValid(token);
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{valid
|
||||||
|
? "Neues Passwort festlegen"
|
||||||
|
: "Dieser Link ist nicht mehr gültig"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{valid
|
||||||
|
? "Wähle ein neues Passwort für dein Kita-Planer-Konto."
|
||||||
|
: "Der Link ist abgelaufen, wurde bereits verwendet oder ist ungültig. Du kannst einen neuen Link anfordern."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{valid ? (
|
||||||
|
<PasswordForm mode="reset" token={token} />
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/passwort-vergessen"
|
||||||
|
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Neuen Link anfordern
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Bell,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Clock,
|
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
FileStack,
|
FileStack,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
@@ -16,7 +13,6 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
PhoneCall,
|
PhoneCall,
|
||||||
Search,
|
|
||||||
Route,
|
Route,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Table2,
|
Table2,
|
||||||
@@ -35,38 +31,23 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { marketingFeatures } from "@/lib/marketing/features";
|
import { marketingFeatures } from "@/lib/marketing/features";
|
||||||
|
import { StructuredData } from "@/components/marketing/structured-data";
|
||||||
|
import {
|
||||||
|
landingStructuredData,
|
||||||
|
marketingDescription,
|
||||||
|
marketingTitle,
|
||||||
|
publicPageMetadata,
|
||||||
|
} from "@/lib/marketing/seo";
|
||||||
|
import { ProductScreenshots, ScreenshotPreview } from "@/components/marketing/product-screenshots";
|
||||||
|
import { getProductScreenshot } from "@/lib/marketing/screenshots";
|
||||||
|
import { ProductVideo } from "@/components/marketing/product-video";
|
||||||
import { ContactForm } from "./contact-form";
|
import { ContactForm } from "./contact-form";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = publicPageMetadata(
|
||||||
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
|
"/",
|
||||||
description:
|
marketingTitle,
|
||||||
"Die einfache Plattform für Elterninitiativen, freie Kitas und Kita-Vereine: organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
|
marketingDescription,
|
||||||
alternates: {
|
);
|
||||||
canonical: "/",
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: "Kita-Planer für Elterninitiativen und Kita-Vereine",
|
|
||||||
description:
|
|
||||||
"Weniger Chat-Chaos, weniger Excel-Listen, mehr Ruhe im ehrenamtlichen Kita-Alltag.",
|
|
||||||
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: "Kita-Planer für Elterninitiativen und Kita-Vereine",
|
|
||||||
description:
|
|
||||||
"Organisiert Dienste, Termine, Krankmeldungen und offizielle Kommunikation an einem Ort.",
|
|
||||||
images: ["/og-image.png"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const heroImage =
|
const heroImage =
|
||||||
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
|
"https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?auto=format&fit=crop&w=2400&q=86";
|
||||||
@@ -74,13 +55,13 @@ const heroImage =
|
|||||||
const privacyPoints = [
|
const privacyPoints = [
|
||||||
{
|
{
|
||||||
icon: DatabaseZap,
|
icon: DatabaseZap,
|
||||||
title: "Strikt isolierte Mandanten",
|
title: "Getrennte Daten je Kita",
|
||||||
text: "Jede Kita arbeitet technisch streng getrennt pro Kita. Rollen und Datenabfragen bleiben konsequent tenant-isoliert.",
|
text: "Die Daten eurer Kita sind von anderen Einrichtungen getrennt. Zugriff erhalten nur die berechtigten Konten eurer Kita.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Fingerprint,
|
icon: Fingerprint,
|
||||||
title: "Kontaktfreigabe per Opt-in",
|
title: "Kontaktfreigabe per Opt-in",
|
||||||
text: "Adressbuchdaten erscheinen nur nach aktivem Opt-In pro Elternteil. Nicht freigegebene Kontaktdaten verlassen den Server nicht.",
|
text: "Jeder Elternteil entscheidet über die Freigabe der eigenen Kontaktdaten. Im internen Adressbuch erscheinen nur freigegebene Angaben.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -90,23 +71,23 @@ const privacyPoints = [
|
|||||||
{
|
{
|
||||||
icon: LockKeyhole,
|
icon: LockKeyhole,
|
||||||
title: "Geschützte Anhänge",
|
title: "Geschützte Anhänge",
|
||||||
text: "Dokumente vom Schwarzen Brett liegen nicht öffentlich und werden nur nach Session- und Kita-Prüfung ausgeliefert.",
|
text: "Anhänge sind nur nach Anmeldung und für die gewählte Empfängergruppe eurer Kita abrufbar.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
title: "Rollenrechte pro Kita",
|
title: "Rollenrechte pro Kita",
|
||||||
text: "Vorstand, Koordination, Eltern und Team sehen nur die Bereiche, die zu ihrer Aufgabe und Kita-Zugehörigkeit passen.",
|
text: "Kita-Admins vergeben Rechte für Verwaltung, Notdienst-Koordination, Eltern und Team. Ämter wie Vorstand sind von Zugriffsrechten getrennt.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FileStack,
|
icon: FileStack,
|
||||||
title: "Klare Datenpfade",
|
title: "Kontozugang selbst verwalten",
|
||||||
text: "Löschungen sind bewusst vorgesehen; Fragen zu Export und Übergabe werden im Setup sauber geklärt.",
|
text: "Aktivierte Konten können ihr Passwort im Profil ändern oder einen zeitlich begrenzten Wiederherstellungslink anfordern.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const proofPoints = [
|
const proofPoints = [
|
||||||
"Für Elterninitiativen, freie Kitas und Kita-Vereine",
|
"Für Elterninitiativen, freie Kitas und Kita-Vereine",
|
||||||
"Dienste, Kalender, Abwesenheiten und offizielle Infos in einem System",
|
"Dienste, Kalender, Arbeitszeit und offizielle Infos in einem System",
|
||||||
"Gebaut für Vorstände, Eltern, Koordination und Team",
|
"Gebaut für Vorstände, Eltern, Koordination und Team",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -114,13 +95,13 @@ const replacedTools = [
|
|||||||
{
|
{
|
||||||
icon: MessagesSquare,
|
icon: MessagesSquare,
|
||||||
title: "WhatsApp für offizielle Infos",
|
title: "WhatsApp für offizielle Infos",
|
||||||
text: "Ankündigungen, Anhänge und Lesestatus gehören in einen verbindlichen Kanal, nicht zwischen spontane Rückfragen.",
|
text: "Ankündigungen mit Anhängen, sichtbaren Korrekturen und optionalen E-Mails bleiben auch später auffindbar.",
|
||||||
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
|
accent: "bg-emerald-50 text-emerald-700 ring-emerald-100",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Table2,
|
icon: Table2,
|
||||||
title: "Excel-Listen für Dienste",
|
title: "Excel-Listen für Dienste",
|
||||||
text: "Verfügbarkeiten, Mindesttage und Dienstpläne werden strukturierter als in Tabellen mit Version 17-final-final.",
|
text: "Verfügbarkeiten, Erinnerungen und veröffentlichte Dienstpläne bleiben nachvollziehbar. Bei Bedarf lässt sich die Einteilung manuell anpassen.",
|
||||||
accent: "bg-lime-50 text-lime-700 ring-lime-100",
|
accent: "bg-lime-50 text-lime-700 ring-lime-100",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -147,17 +128,22 @@ const audienceRoles = [
|
|||||||
{
|
{
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
title: "Vorstand & Admin",
|
title: "Vorstand & Admin",
|
||||||
text: "Kita, Familien, Termine, Einladungen und Rechte verwalten, ohne dass alles an einer privaten Ablage hängt.",
|
text: "Familien einladen, Zugriffsrechte vergeben, Mitteilungen korrigieren und Versandfehler gezielt erneut versuchen.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: UsersRound,
|
icon: UsersRound,
|
||||||
title: "Eltern",
|
title: "Eltern",
|
||||||
text: "Termine sehen, Kinder krankmelden, Verfügbarkeiten eintragen und freigegebene Kontakte finden.",
|
text: "Verfügbarkeiten eintragen, zu Terminen zusagen, Mitbringbeiträge verwalten und zukünftige Abwesenheiten korrigieren.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: HeartHandshake,
|
icon: HeartHandshake,
|
||||||
title: "ErzieherInnen & Koordination",
|
title: "Kita-Team",
|
||||||
text: "Tagesüberblick, Abwesenheiten, Dienste und relevante Informationen an einem Ort sehen.",
|
text: "Arbeitszeit erfassen, Wochen einreichen und Urlaub oder Freizeitausgleich beantragen. Abwesenheiten und Teammitteilungen bleiben im Blick.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: HeartHandshake,
|
||||||
|
title: "Notdienst-Koordination",
|
||||||
|
text: "Verfügbarkeiten prüfen, Monatspläne veröffentlichen und bei Ausfällen mit Rückmeldungen aus den Haushalten weiterplanen.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -190,28 +176,32 @@ const onboardingSteps = [
|
|||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
|
<div className="min-h-screen bg-[#f8faf8] text-slate-950">
|
||||||
|
<StructuredData data={landingStructuredData(marketingFeatures)} />
|
||||||
<header className="absolute left-0 right-0 top-0 z-20">
|
<header className="absolute left-0 right-0 top-0 z-20">
|
||||||
<div className="mx-auto flex min-h-20 w-full max-w-7xl items-center justify-between gap-3 px-5 py-4 sm:px-6">
|
<div className="mx-auto flex min-h-20 w-full max-w-7xl items-center justify-between gap-3 px-5 py-4 sm:px-6">
|
||||||
<Link href="/" className="flex items-center gap-3 text-white">
|
<Link
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30">
|
href="/"
|
||||||
|
className="flex shrink-0 items-center gap-2 text-white sm:gap-3"
|
||||||
|
>
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/12 ring-1 ring-white/30 sm:h-9 sm:w-9">
|
||||||
<ShieldCheck className="h-5 w-5" />
|
<ShieldCheck className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base font-semibold tracking-tight">
|
<span className="whitespace-nowrap text-sm font-semibold tracking-tight sm:text-base">
|
||||||
Kita-Planer
|
Kita-Planer
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex flex-wrap items-center justify-end gap-2">
|
<nav className="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-white hover:bg-white/10 hover:text-white"
|
className="px-3 text-xs text-white hover:bg-white/10 hover:text-white sm:px-4 sm:text-sm"
|
||||||
>
|
>
|
||||||
<Link href="/login">Login</Link>
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
className="bg-white text-slate-950 shadow-sm hover:bg-white/90"
|
className="bg-white px-3 text-xs text-slate-950 shadow-sm hover:bg-white/90 sm:px-4 sm:text-sm"
|
||||||
>
|
>
|
||||||
<Link href="/#kontakt">Demo anfragen</Link>
|
<Link href="/#kontakt">Demo anfragen</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -245,8 +235,8 @@ export default function LandingPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
|
<p className="mt-6 max-w-2xl text-lg leading-8 text-white/82 sm:text-xl">
|
||||||
Für Elterninitiativen, freie Kitas und Kita-Vereine, die
|
Für Elterninitiativen, freie Kitas und Kita-Vereine, die
|
||||||
Dienste, Termine, Krankmeldungen und offizielle Kommunikation
|
Dienste, Termine, Abwesenheiten, Arbeitszeit und offizielle
|
||||||
ehrenamtlich organisieren.
|
Kommunikation gemeinsam organisieren.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
|
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
|
||||||
@@ -255,10 +245,10 @@ export default function LandingPage() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
|
className="h-12 bg-white px-6 text-slate-950 hover:bg-white/90"
|
||||||
>
|
>
|
||||||
<Link href="/#kontakt">
|
<a href="#produkt-video">
|
||||||
Demo anfragen
|
Video ansehen
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Link>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
@@ -282,6 +272,8 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ProductVideo />
|
||||||
|
|
||||||
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
|
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
|
||||||
<div className="mb-10 grid gap-5 lg:grid-cols-[0.78fr_1fr] lg:items-end">
|
<div className="mb-10 grid gap-5 lg:grid-cols-[0.78fr_1fr] lg:items-end">
|
||||||
<div>
|
<div>
|
||||||
@@ -294,13 +286,14 @@ export default function LandingPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
|
<p className="max-w-2xl text-sm leading-6 text-slate-600 lg:justify-self-end">
|
||||||
Der Notdienst-Planer sammelt Verfügbarkeiten, zeigt offene
|
Entdeckt die sechs Bereiche direkt in der App: vom Notdienst
|
||||||
Familien und macht aus losen Antworten einen veröffentlichbaren
|
über Kalender und Mitteilungen bis zur Arbeitszeit. Die Ansichten
|
||||||
Monatsplan.
|
zeigen Beispieldaten und lassen sich für einen genaueren Blick
|
||||||
|
vergrößern.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductBrowserMock />
|
<ProductScreenshots />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
|
<section className="mx-auto w-full max-w-7xl px-5 py-20 sm:px-6 lg:py-24">
|
||||||
@@ -386,11 +379,14 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6">
|
<section
|
||||||
|
id="funktionen"
|
||||||
|
className="mx-auto w-full max-w-7xl px-5 py-24 sm:px-6"
|
||||||
|
>
|
||||||
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
<div className="mb-12 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
|
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-emerald-700">
|
||||||
Die 5 Säulen
|
Die {marketingFeatures.length} Bereiche
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
<h2 className="text-balance text-3xl font-semibold leading-tight sm:text-4xl">
|
||||||
Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam
|
Plant Dienste, teilt Neuigkeiten und behaltet Termine gemeinsam
|
||||||
@@ -405,15 +401,12 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-6">
|
||||||
{marketingFeatures.map((feature, index) => {
|
{marketingFeatures.map((feature) => {
|
||||||
const Icon = feature.icon;
|
const Icon = feature.icon;
|
||||||
const isWide = index > 2;
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={feature.title}
|
key={feature.title}
|
||||||
className={`group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg ${
|
className="group border-slate-200 bg-white/90 shadow-sm transition duration-200 hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg xl:col-span-2"
|
||||||
isWide ? "xl:col-span-3" : "xl:col-span-2"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<CardHeader className="space-y-5">
|
<CardHeader className="space-y-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
@@ -440,7 +433,10 @@ export default function LandingPage() {
|
|||||||
variant="link"
|
variant="link"
|
||||||
className="mt-4 h-auto p-0 text-slate-950"
|
className="mt-4 h-auto p-0 text-slate-950"
|
||||||
>
|
>
|
||||||
<Link href={`/features/${feature.slug}`}>
|
<Link
|
||||||
|
href={`/features/${feature.slug}`}
|
||||||
|
aria-label={`Mehr über ${feature.shortTitle} erfahren`}
|
||||||
|
>
|
||||||
Mehr erfahren
|
Mehr erfahren
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -448,7 +444,7 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<FeaturePreview slug={feature.slug} />
|
<ScreenshotPreview screenshot={getProductScreenshot(feature.slug)!} compact />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -508,9 +504,9 @@ export default function LandingPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
|
<p className="mt-5 max-w-xl text-base leading-7 text-slate-300">
|
||||||
Kita-Vereine verwalten besonders sensible Daten. Deshalb ist
|
Kita-Vereine verwalten besonders sensible Daten. Deshalb ist
|
||||||
Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte,
|
Kita-Planer so gebaut, dass Sichtbarkeit, Rollenrechte, Löschung
|
||||||
Löschung und Mandantentrennung nicht vom guten Willen einzelner
|
und Mandantentrennung nicht vom guten Willen einzelner Chats
|
||||||
Chats abhängen.
|
abhängen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -634,133 +630,3 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProductBrowserMock() {
|
|
||||||
return (
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-emerald-100 bg-white shadow-[0_24px_70px_rgba(15,23,42,0.12)]">
|
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 bg-[#fbf8ef] px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="h-3 w-3 rounded-full bg-red-300" />
|
|
||||||
<span className="h-3 w-3 rounded-full bg-amber-300" />
|
|
||||||
<span className="h-3 w-3 rounded-full bg-emerald-300" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-full border border-slate-200 bg-white px-4 py-1 text-xs font-medium text-slate-500">
|
|
||||||
app.kita-planer.de/dashboard/notdienst
|
|
||||||
</div>
|
|
||||||
<div className="w-16" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[#fbf8ef] p-2 sm:p-4">
|
|
||||||
<Image
|
|
||||||
src="/landing/notdienst-calendar-linen.png"
|
|
||||||
alt="Notdienst-Kalender im Kita-Planer mit ausgewählten Tagen im Juni 2026"
|
|
||||||
width={2262}
|
|
||||||
height={1640}
|
|
||||||
sizes="(min-width: 1280px) 1184px, calc(100vw - 40px)"
|
|
||||||
className="h-auto w-full rounded-xl border border-slate-200 bg-[#fbf8ef]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeaturePreview({ slug }: { slug: string }) {
|
|
||||||
if (slug === "notdienst-planer") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-red-100 bg-[#fbf8ef] p-3">
|
|
||||||
<div className="mb-3 flex items-center justify-between text-xs">
|
|
||||||
<span className="font-semibold text-slate-700">Juni</span>
|
|
||||||
<span className="rounded-full bg-red-50 px-2 py-1 text-red-700">
|
|
||||||
12/16
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
|
||||||
{["", "bg-red-100", "", "bg-red-100", "", "bg-red-100", "", "", "bg-red-100", ""].map(
|
|
||||||
(cell, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={`h-7 rounded-md border border-red-100 bg-white ${cell}`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug === "kita-kalender") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-sky-100 bg-[#fbf8ef] p-3">
|
|
||||||
{[
|
|
||||||
["10", "Schließtag", "bg-red-400"],
|
|
||||||
["18", "Elternabend", "bg-indigo-400"],
|
|
||||||
["29", "Sommerfest", "bg-emerald-500"],
|
|
||||||
].map(([date, title, color]) => (
|
|
||||||
<div
|
|
||||||
key={title}
|
|
||||||
className="mb-2 last:mb-0 flex items-center gap-3 rounded-lg bg-white px-3 py-2 text-xs"
|
|
||||||
>
|
|
||||||
<span className="font-semibold tabular-nums text-slate-950">
|
|
||||||
{date}
|
|
||||||
</span>
|
|
||||||
<span className={`h-5 w-1 rounded-full ${color}`} />
|
|
||||||
<span className="text-slate-600">{title}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug === "eltern-adressbuch") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-emerald-100 bg-[#fbf8ef] p-3">
|
|
||||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-400">
|
|
||||||
<Search className="h-3.5 w-3.5" />
|
|
||||||
Familie suchen
|
|
||||||
</div>
|
|
||||||
{["Meier", "Mustermann"].map((name) => (
|
|
||||||
<div key={name} className="mb-2 last:mb-0 flex items-center gap-2">
|
|
||||||
<span className="h-7 w-7 rounded-lg bg-emerald-50" />
|
|
||||||
<span className="h-2 flex-1 rounded-full bg-slate-200" />
|
|
||||||
<span className="h-2 w-10 rounded-full bg-emerald-100" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug === "krankmeldung") {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-amber-100 bg-[#fbf8ef] p-3">
|
|
||||||
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
|
|
||||||
<Clock className="h-4 w-4 text-amber-700" />
|
|
||||||
Heute fehlen 2 Kinder
|
|
||||||
</div>
|
|
||||||
{["Kai · krank", "Mia · Arzttermin"].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item}
|
|
||||||
className="mb-2 last:mb-0 rounded-lg bg-white px-3 py-2 text-xs text-slate-600"
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-indigo-100 bg-[#fbf8ef] p-3">
|
|
||||||
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-slate-700">
|
|
||||||
<Bell className="h-4 w-4 text-indigo-700" />
|
|
||||||
Neue Mitteilung
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="block h-2.5 w-4/5 rounded-full bg-slate-200" />
|
|
||||||
<span className="block h-2.5 w-3/5 rounded-full bg-slate-200" />
|
|
||||||
<span className="inline-flex rounded-full bg-indigo-50 px-2 py-1 text-xs text-indigo-700">
|
|
||||||
PDF angehängt
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { after } from "next/server";
|
||||||
|
import { limitPublicAction } from "@/lib/rate-limit";
|
||||||
|
import {
|
||||||
|
requestPasswordReset,
|
||||||
|
passwordResetMessage,
|
||||||
|
resetPassword,
|
||||||
|
changeOwnPassword,
|
||||||
|
} from "@/lib/password-management";
|
||||||
|
import { hashToken } from "@/lib/tokens";
|
||||||
|
import { requireSession } from "@/lib/auth-utils";
|
||||||
|
import { publicActionError } from "@/lib/action-error";
|
||||||
|
|
||||||
|
export type PasswordActionState = {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
export async function requestResetAction(
|
||||||
|
_previous: PasswordActionState,
|
||||||
|
form: FormData,
|
||||||
|
): Promise<PasswordActionState> {
|
||||||
|
const email = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(254)
|
||||||
|
.email()
|
||||||
|
.toLowerCase()
|
||||||
|
.safeParse(form.get("email"));
|
||||||
|
if (!email.success)
|
||||||
|
return { error: "Bitte gib eine gültige E-Mail-Adresse ein." };
|
||||||
|
try {
|
||||||
|
if (await limitPublicAction("password-reset-request", email.data, 3, 3600))
|
||||||
|
after(async () => {
|
||||||
|
try {
|
||||||
|
await requestPasswordReset(email.data);
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
"Passwort-Wiederherstellung konnte nicht vorbereitet werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { success: true, message: passwordResetMessage };
|
||||||
|
}
|
||||||
|
export async function finishResetAction(
|
||||||
|
_previous: PasswordActionState,
|
||||||
|
form: FormData,
|
||||||
|
): Promise<PasswordActionState> {
|
||||||
|
const token = form.get("token");
|
||||||
|
if (typeof token !== "string" || !/^[a-f0-9]{64}$/i.test(token))
|
||||||
|
return {
|
||||||
|
error: "Dieser Link ist ungültig. Bitte fordere einen neuen Link an.",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!(await limitPublicAction(
|
||||||
|
"password-reset-complete",
|
||||||
|
hashToken(token),
|
||||||
|
5,
|
||||||
|
900,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
|
||||||
|
await resetPassword({
|
||||||
|
token,
|
||||||
|
password: form.get("password"),
|
||||||
|
confirmation: form.get("confirmation"),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Dein Passwort wurde geändert. Melde dich mit deinem neuen Passwort an.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function changePasswordAction(
|
||||||
|
_previous: PasswordActionState,
|
||||||
|
form: FormData,
|
||||||
|
): Promise<PasswordActionState> {
|
||||||
|
const { user } = await requireSession();
|
||||||
|
try {
|
||||||
|
if (!(await limitPublicAction("password-change", user.id, 5, 900)))
|
||||||
|
return { error: "Zu viele Versuche. Bitte versuche es später erneut." };
|
||||||
|
await changeOwnPassword(
|
||||||
|
{ id: user.id, sessionVersion: user.sessionVersion },
|
||||||
|
{
|
||||||
|
currentPassword: form.get("currentPassword"),
|
||||||
|
password: form.get("password"),
|
||||||
|
confirmation: form.get("confirmation"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Dein Passwort wurde geändert. Bitte melde dich erneut an.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: publicActionError(
|
||||||
|
error,
|
||||||
|
"Das Passwort konnte nicht geändert werden. Bitte versuche es erneut.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { PasswordForm } from "./password-form";
|
||||||
|
export const metadata = {
|
||||||
|
title: "Passwort vergessen · Kita-Planer",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Passwort vergessen?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Fordere einen persönlichen Link an, um ein neues Passwort
|
||||||
|
festzulegen. Der Link ist 30 Minuten gültig.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<PasswordForm mode="request" />
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-block text-sm text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useActionState, useEffect, useId } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
requestResetAction,
|
||||||
|
finishResetAction,
|
||||||
|
changePasswordAction,
|
||||||
|
type PasswordActionState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
export function PasswordForm({
|
||||||
|
mode,
|
||||||
|
token,
|
||||||
|
}: {
|
||||||
|
mode: "request" | "reset" | "change";
|
||||||
|
token?: string;
|
||||||
|
}) {
|
||||||
|
const action =
|
||||||
|
mode === "request"
|
||||||
|
? requestResetAction
|
||||||
|
: mode === "reset"
|
||||||
|
? finishResetAction
|
||||||
|
: changePasswordAction;
|
||||||
|
const [state, formAction, pending] = useActionState(
|
||||||
|
action,
|
||||||
|
{} as PasswordActionState,
|
||||||
|
);
|
||||||
|
const id = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "change" && state.success)
|
||||||
|
window.location.replace(
|
||||||
|
new URL("/login?passwordChanged=1", window.location.origin).href,
|
||||||
|
);
|
||||||
|
}, [mode, state.success]);
|
||||||
|
if (state.success)
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="rounded-xl border border-[var(--hairline)] bg-[var(--good-soft)] p-4 text-sm leading-6 text-[var(--ink)]"
|
||||||
|
>
|
||||||
|
{state.message}
|
||||||
|
</p>
|
||||||
|
{mode !== "request" ? (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Zur Anmeldung
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
{token ? <input type="hidden" name="token" value={token} /> : null}
|
||||||
|
{mode === "request" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-email`}>E-Mail-Adresse</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-email`}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
maxLength={254}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{mode === "change" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-current`}>Aktuelles Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-current`}
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-password`}>Neues Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-password`}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
Mindestens 8 Zeichen. Verwende ein eigenes Passwort für den
|
||||||
|
Kita-Planer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${id}-confirmation`}>
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${id}-confirmation`}
|
||||||
|
name="confirmation"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||||
|
Nach dem Speichern werden alle bisherigen Anmeldungen beendet.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state.error ? (
|
||||||
|
<p role="alert" className="text-sm leading-6 text-[var(--danger)]">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" className="w-full sm:w-auto" disabled={pending}>
|
||||||
|
{pending
|
||||||
|
? "Wird verarbeitet …"
|
||||||
|
: mode === "request"
|
||||||
|
? "Link anfordern"
|
||||||
|
: "Passwort speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,26 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
function getSiteUrl() {
|
import { getPublicSiteOrigin, publicUrl } from "@/lib/marketing/seo";
|
||||||
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
const siteUrl = getSiteUrl();
|
const siteUrl = getPublicSiteOrigin();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
allow: ["/", "/features/", "/impressum", "/datenschutz"],
|
allow: ["/", "/features/", "/impressum", "/datenschutz"],
|
||||||
|
// Login/registration/recovery forms stay crawlable so bots can read noindex.
|
||||||
disallow: [
|
disallow: [
|
||||||
"/api/",
|
"/api/",
|
||||||
"/dashboard/",
|
"/dashboard",
|
||||||
|
"/admin",
|
||||||
"/invite/",
|
"/invite/",
|
||||||
"/alert/",
|
"/alert/",
|
||||||
"/login",
|
"/onboarding",
|
||||||
"/register",
|
"/neues-passwort/",
|
||||||
"/onboarding/",
|
|
||||||
"/forbidden",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
sitemap: `${siteUrl}/sitemap.xml`,
|
sitemap: publicUrl("/sitemap.xml"),
|
||||||
host: siteUrl,
|
host: siteUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,25 @@ import type { MetadataRoute } from "next";
|
|||||||
|
|
||||||
import { marketingFeatures } from "@/lib/marketing/features";
|
import { marketingFeatures } from "@/lib/marketing/features";
|
||||||
|
|
||||||
function getSiteUrl() {
|
import { marketingLastModified, publicUrl } from "@/lib/marketing/seo";
|
||||||
return process.env.NEXT_PUBLIC_SITE_URL ?? "https://mein-kitaplaner.de";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const siteUrl = getSiteUrl();
|
|
||||||
const lastModified = new Date();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: siteUrl,
|
url: publicUrl(),
|
||||||
lastModified,
|
lastModified: marketingLastModified,
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "monthly",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
|
{ url: publicUrl("/impressum"), changeFrequency: "yearly", priority: 0.2 },
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/impressum`,
|
url: publicUrl("/datenschutz"),
|
||||||
lastModified,
|
|
||||||
changeFrequency: "yearly",
|
|
||||||
priority: 0.2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${siteUrl}/datenschutz`,
|
|
||||||
lastModified,
|
|
||||||
changeFrequency: "yearly",
|
changeFrequency: "yearly",
|
||||||
priority: 0.2,
|
priority: 0.2,
|
||||||
},
|
},
|
||||||
...marketingFeatures.map((feature) => ({
|
...marketingFeatures.map((feature) => ({
|
||||||
url: `${siteUrl}/features/${feature.slug}`,
|
url: publicUrl(`/features/${feature.slug}`),
|
||||||
lastModified,
|
lastModified: marketingLastModified,
|
||||||
changeFrequency: "monthly" as const,
|
changeFrequency: "monthly" as const,
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
})),
|
})),
|
||||||
|
|||||||