From 043fed2e46766409fc70b908b12a4e6f12f6ebb7 Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Wed, 7 Oct 2026 15:25:55 +0200 Subject: [PATCH] Add educator leave management and working time accounts --- deploy/sql/personalbereich.sql | 254 ++++ docs/FUNKTIONSUEBERSICHT.md | 438 ++++++ docs/PERSONALBEREICH.md | 97 ++ package.json | 2 + prisma/schema.prisma | 248 +++- .../erzieher/_components/erzieher-list.tsx | 176 ++- src/app/dashboard/erzieher/actions.ts | 24 +- src/app/dashboard/erzieher/page.tsx | 4 +- src/app/dashboard/page.tsx | 44 +- src/app/dashboard/personal/actions.ts | 45 + src/app/dashboard/personal/forms.tsx | 757 ++++++++++ src/app/dashboard/personal/page.tsx | 1244 +++++++++++++++++ src/app/dashboard/profil/page.tsx | 228 +-- src/components/dashboard/sidebar-nav.tsx | 10 + src/lib/staff/auth.ts | 28 + src/lib/staff/calculations.ts | 315 +++++ src/lib/staff/service.ts | 1162 +++++++++++++++ tests/staff-calculations.test.ts | 237 ++++ tests/staff-service.test.ts | 518 +++++++ 19 files changed, 5631 insertions(+), 200 deletions(-) create mode 100644 deploy/sql/personalbereich.sql create mode 100644 docs/FUNKTIONSUEBERSICHT.md create mode 100644 docs/PERSONALBEREICH.md create mode 100644 src/app/dashboard/personal/actions.ts create mode 100644 src/app/dashboard/personal/forms.tsx create mode 100644 src/app/dashboard/personal/page.tsx create mode 100644 src/lib/staff/auth.ts create mode 100644 src/lib/staff/calculations.ts create mode 100644 src/lib/staff/service.ts create mode 100644 tests/staff-calculations.test.ts create mode 100644 tests/staff-service.test.ts diff --git a/deploy/sql/personalbereich.sql b/deploy/sql/personalbereich.sql new file mode 100644 index 0000000..5314b4f --- /dev/null +++ b/deploy/sql/personalbereich.sql @@ -0,0 +1,254 @@ +-- Einmalige additive Änderung gegen Ausgangscommit ec3f4f9. +-- Alternativ zum bestehenden prisma db push, nicht danach erneut ausführen. +BEGIN; + +-- CreateEnum +CREATE TYPE "StaffRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCEL_REQUESTED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "StaffLeaveKind" AS ENUM ('VACATION', 'TIME_OFF', 'SICKNESS'); + +-- CreateEnum +CREATE TYPE "StaffWeekStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'APPROVED'); + +-- CreateTable +CREATE TABLE "staff_profiles" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "educatorId" TEXT NOT NULL, + "userId" TEXT, + "employmentStart" DATE NOT NULL, + "employmentEnd" DATE, + "trackingStart" DATE NOT NULL, + "openingMinutes" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "staff_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_work_patterns" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "effectiveFrom" DATE NOT NULL, + "dailyMinutes" INTEGER[], + + CONSTRAINT "staff_work_patterns_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_day_overrides" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "date" DATE NOT NULL, + "minutes" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + + CONSTRAINT "staff_day_overrides_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_holidays" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "date" DATE NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "staff_holidays_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_leave_accounts" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "entitlementUnits" INTEGER NOT NULL, + "carryUnits" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "staff_leave_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_leaves" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "kind" "StaffLeaveKind" NOT NULL, + "status" "StaffRequestStatus" NOT NULL DEFAULT 'PENDING', + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + "halfDay" BOOLEAN NOT NULL DEFAULT false, + "minutes" INTEGER, + "note" TEXT, + "decisionNote" TEXT, + "requestedById" TEXT NOT NULL, + "reviewedById" TEXT, + "reviewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "staff_leaves_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_leave_days" ( + "id" TEXT NOT NULL, + "leaveId" TEXT NOT NULL, + "date" DATE NOT NULL, + "vacationUnits" INTEGER NOT NULL DEFAULT 0, + "creditMinutes" INTEGER NOT NULL, + + CONSTRAINT "staff_leave_days_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_time_entries" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "date" DATE NOT NULL, + "startMinute" INTEGER NOT NULL, + "endMinute" INTEGER NOT NULL, + "breakMinutes" INTEGER NOT NULL DEFAULT 0, + "note" TEXT, + + CONSTRAINT "staff_time_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_weeks" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "weekStart" DATE NOT NULL, + "status" "StaffWeekStatus" NOT NULL DEFAULT 'DRAFT', + "submittedAt" TIMESTAMP(3), + "reviewedAt" TIMESTAMP(3), + "reviewedById" TEXT, + "decisionNote" TEXT, + "dailySnapshot" JSONB, + + CONSTRAINT "staff_weeks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_adjustments" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "date" DATE NOT NULL, + "minutes" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "staff_adjustments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_history" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "details" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "staff_history_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_notifications" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "message" TEXT NOT NULL, + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "staff_notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_profiles_educatorId_key" ON "staff_profiles"("educatorId"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_profiles_userId_key" ON "staff_profiles"("userId"); + +-- CreateIndex +CREATE INDEX "staff_profiles_kitaId_idx" ON "staff_profiles"("kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_work_patterns_profileId_effectiveFrom_key" ON "staff_work_patterns"("profileId", "effectiveFrom"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_day_overrides_profileId_date_key" ON "staff_day_overrides"("profileId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_holidays_kitaId_date_key" ON "staff_holidays"("kitaId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_leave_accounts_profileId_year_key" ON "staff_leave_accounts"("profileId", "year"); + +-- CreateIndex +CREATE INDEX "staff_leaves_profileId_status_startDate_idx" ON "staff_leaves"("profileId", "status", "startDate"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_leave_days_leaveId_date_key" ON "staff_leave_days"("leaveId", "date"); + +-- CreateIndex +CREATE INDEX "staff_time_entries_profileId_date_idx" ON "staff_time_entries"("profileId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_weeks_profileId_weekStart_key" ON "staff_weeks"("profileId", "weekStart"); + +-- CreateIndex +CREATE INDEX "staff_adjustments_profileId_date_idx" ON "staff_adjustments"("profileId", "date"); + +-- CreateIndex +CREATE INDEX "staff_history_profileId_createdAt_idx" ON "staff_history"("profileId", "createdAt"); + +-- CreateIndex +CREATE INDEX "staff_notifications_userId_readAt_createdAt_idx" ON "staff_notifications"("userId", "readAt", "createdAt"); + +-- AddForeignKey +ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE NO ACTION ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_work_patterns" ADD CONSTRAINT "staff_work_patterns_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_day_overrides" ADD CONSTRAINT "staff_day_overrides_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_holidays" ADD CONSTRAINT "staff_holidays_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_leave_accounts" ADD CONSTRAINT "staff_leave_accounts_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_leaves" ADD CONSTRAINT "staff_leaves_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_leave_days" ADD CONSTRAINT "staff_leave_days_leaveId_fkey" FOREIGN KEY ("leaveId") REFERENCES "staff_leaves"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_time_entries" ADD CONSTRAINT "staff_time_entries_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_weeks" ADD CONSTRAINT "staff_weeks_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_adjustments" ADD CONSTRAINT "staff_adjustments_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_history" ADD CONSTRAINT "staff_history_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; diff --git a/docs/FUNKTIONSUEBERSICHT.md b/docs/FUNKTIONSUEBERSICHT.md new file mode 100644 index 0000000..801905d --- /dev/null +++ b/docs/FUNKTIONSUEBERSICHT.md @@ -0,0 +1,438 @@ +# Kita-Planer – Funktionsübersicht und aktueller Stand + +Stand: **07.10.2026** + +Geprüfter Ausgangsbranch: `codex/notdienst-swaps` + +Geprüfter Commit: `ec3f4f933d42068c9d8446dc95de9815d5779238` + +Dokumentationsbranch: `codex/funktionsinventar` + +**Ergänzung vom 07.10.2026:** Auf `codex/personalbereich` wurde anschließend der Personalbereich für Arbeitszeit, Urlaub und Freizeitausgleich umgesetzt. Diese Bestandsaufnahme bleibt als Beschreibung des Ausgangsstands erhalten; der neue Umfang, Berechnungsregeln, Einrichtung und aktuelle Prüfungen stehen in [PERSONALBEREICH.md](PERSONALBEREICH.md). Die Aussagen unten zu fehlender Zeiterfassung, Accountverknüpfung und Tests beziehen sich auf den Ausgangscommit. + +## 1. Grundlage der Prüfung + +Diese Bestandsaufnahme beschreibt die im Repository vorhandenen Produktfunktionen. Geprüft wurden Seiten und Navigation, Formulare und Dialoge, Server Actions, API-Routen, Datenmodell, E-Mail-Anbindung und Betriebsdateien. Die Planung in `planung.md`, die UI-Liste in `UI-TODO.md` und der frühere Live-Testplan wurden mit dem aktuellen Code abgeglichen. + +**„Vorhanden“ bedeutet hier: Oberfläche und zugehörige Verarbeitung sind im Code angebunden. Es bedeutet keine Bestätigung durch einen aktuellen Live-Test.** Es wurden keine Produktionsdaten verändert, keine E-Mails verschickt und keine Datenbankänderungen ausgeführt. Die tatsächliche Zustellung von E-Mails, das Verhalten im Browser und die produktive Konfiguration bleiben gesondert zu prüfen. + +Status: + +| Kennzeichnung | Bedeutung | +| --- | --- | +| Vorhanden | Im aktuellen Code als erreichbarer Ablauf umgesetzt. | +| Teilweise | Funktion vorhanden, aber mit konkreten Einschränkungen oder fehlender Anbindung. | +| Vorbereitet | Datenmodell, Komponente oder Aktion vorhanden; vollständiger Nutzerablauf fehlt. | +| Offen | In der Planung vorgesehen, im geprüften Code nicht umgesetzt. | + +## 2. Gesamtüberblick + +Die Anwendung besitzt bereits eine breite MVP-Grundlage für mehrere unabhängige Kitas: Registrierung, Einrichtung, Familien und Kinder, Notdienst-Verfügbarkeiten und Planung, Diensttausch, Kalender, Mitbringlisten, Abwesenheiten, Kontaktdaten und Superadmin-Support. Einige beworbene Abläufe sind jedoch noch nicht vollständig nutzbar, insbesondere Alarmierung, Eltern-Rückmeldungen zu Events und die Anzeige des Schwarzen Bretts für Eltern. + +| Bereich | Stand | Wichtigste Einschränkung | +| --- | --- | --- | +| Öffentliche Website und Kontakt | Vorhanden | Impressum und Datenschutz enthalten Platzhalter. | +| Registrierung, Login, Kita-Einrichtung | Vorhanden | Kein selbstständiger Passwort-Reset; Modulschalter wirken nur teilweise. | +| Dashboard | Vorhanden | News-, RSVP- und Alarm-Komponenten sind nicht eingebunden. | +| Profil und eigene Kinder | Vorhanden | Persönlicher Name, E-Mail und Rolle sind im Profil nur lesbar. | +| Familienverwaltung und feste Ämter | Vorhanden | Kein vollständiger Einladungs-Support mit erneutem Versand/Widerruf. | +| Notdienst-Verfügbarkeiten | Vorhanden | Wochenenden und Schließtage werden nur in der Oberfläche ausgeschlossen. | +| Notdienst-Planung | Vorhanden | Keine automatische Erinnerung und keine E-Mail bei Erstveröffentlichung. | +| Notdienst-Tausch | Vorhanden | Nur Elternrolle kann neue Tauschanfragen starten. | +| Notdienst-Alarmierung | Vorbereitet | Auslösebutton ist auf keiner Seite eingebunden. | +| Kalender und Mitbringlisten | Vorhanden | Keine Kollisionsprüfung für Raumbuchungen; Mitbringsel nicht direkt bearbeitbar. | +| Event-Teilnahme/RSVP | Teilweise | Verwaltung vorhanden, Eltern-Eingabe nicht in eine Seite eingebunden. | +| Abwesenheiten | Vorhanden | Gruppenfilter verwendet eine künstliche Zuordnung. | +| Schwarzes Brett | Teilweise | Veröffentlichung vorhanden, Elternanzeige und Lesestatus fehlen im aktuellen Dashboard. | +| Adressbuch | Vorhanden | Opt-in gilt für Kontaktdaten; weitere Haushaltsdaten bleiben sichtbar. | +| ErzieherInnen-Stammdaten | Vorhanden | Stammdatensatz und Benutzerrolle `ERZIEHER` sind getrennte Konzepte. | +| Superadmin-Verwaltung | Vorhanden | Einladungszähler verwenden ein anderes Modell als der reale Einladungsablauf. | +| Betrieb und Qualität | Teilweise | TypeScript erfolgreich, ESLint mit Fehlern; keine automatisierte Testsuite gefunden. | + +## 3. Rollen und Zugriffsbereiche + +| Rolle | Aktuelle Möglichkeiten | +| --- | --- | +| `SUPERADMIN` | Systemweite Kita-Übersicht, Kita-Diagnosen, Support-Aktionen und Audit-Protokoll. Standardmäßig ohne Kita-Zuordnung und mit eigener Oberfläche unter `/admin`. | +| `ADMIN` | Verwaltung der eigenen Kita: Familien, Ämter, ErzieherInnen, Dienste, Meldungen, Kalender und Notdienst. Mit eigenem Haushalt auch Elternfunktionen nutzbar. | +| `KOORDINATOR` | Notdienstplanung, Kalenderverwaltung, Event-Anmeldungen und Abwesenheitsübersicht. Mit eigenem Haushalt auch Verfügbarkeiten eintragbar. | +| `ERZIEHER` | Dashboard, Kalender, Adressbuch, Profil und Abwesenheitsübersicht. Kein Zugriff auf die Notdienst-Eingabeseite. | +| `ELTERN` | Eigener Haushalt/Kinder, Verfügbarkeiten, Kalender, Terminanfragen, Mitbringsel, Diensttausch, Abwesenheitsmeldungen und Adressbuch. | + +Navigation und serverseitige Rollenprüfungen sind vorhanden. Die Rolle `ERZIEHER` existiert im Datenmodell und wird in Berechtigungen verwendet. Die ErzieherInnen-Verwaltung erstellt jedoch nur Namen als Stammdaten und keine Benutzerkonten. Ein regulärer Dialog zur Einladung von Erzieher-Accounts oder zur Vergabe von Admin-/Koordinatorrollen wurde nicht gefunden. + +Quellen: [Navigation](../src/components/dashboard/sidebar-nav.tsx), [Zugriffsprüfungen](../src/lib/auth-utils.ts), [Datenmodell](../prisma/schema.prisma). + +## 4. Funktionsinventar nach Bereich + +### 4.1 Öffentliche Website und Kontakt + +- Startseite mit Produktbeschreibung, Funktionsvorschauen, Rollenbeschreibung, Einstieg in Registrierung und Login sowie Kontaktbereich. +- Fünf öffentliche Detailseiten: Notdienst-Planer, Kita-Kalender, Eltern-Adressbuch, Krankmeldung und Schwarzes Brett. +- Kontaktformular für allgemeine Fragen, Demo-Anfragen und individuelles Setup. +- Eingaben: Name, optionale Kita, E-Mail-Adresse, Anfrageart und Nachricht. +- Versand einer Anfrage an den konfigurierten Kontakt-Empfänger und einer Bestätigung an die anfragende Person; passende Antwortadressen werden gesetzt. +- Bot-Schutz durch ein unsichtbares Zusatzfeld und ein Limit von fünf Kontaktanfragen je IP-Adresse pro Stunde. Das Limit liegt im Arbeitsspeicher des jeweiligen Prozesses. +- Sitemap, Robots-Datei und Metadaten für öffentliche Seiten. +- Optionales Umami-Tracking über Umgebungsvariablen, auf eine Liste öffentlicher Seiten beschränkt. Suchparameter und URL-Fragmente werden ausgeschlossen; Do-Not-Track wird berücksichtigt. +- Impressum und Datenschutzerklärung sind als Seiten vorhanden, enthalten aber noch Betreiber-, Hosting- und weitere Platzhalter. + +Quellen: [Startseite](../src/app/page.tsx), [Feature-Inhalte](../src/lib/marketing/features.ts), [Kontaktaktion](../src/actions/contact.ts), [Analytics](../src/components/analytics/umami-analytics.tsx), [Impressum](../src/app/impressum/page.tsx), [Datenschutz](../src/app/datenschutz/page.tsx). + +### 4.2 Registrierung, Anmeldung und Einrichtung + +- Gründerregistrierung mit Vorname, Nachname, E-Mail-Adresse, Passwort und verpflichtender Datenschutzannahme. +- Prüfung auf bereits verwendete E-Mail-Adressen und Passwort-Hashing mit bcrypt. +- Gründer erhält zunächst die Adminrolle ohne Kita-Zuordnung und wird direkt angemeldet. +- Kita-Einrichtung mit Name, eindeutigem Slug, Aktivierung von Notdienst/Kalender/Adressbuch und Mindestzahl an Verfügbarkeiten pro Kind. +- Bei aktivem Notdienstmodul ist mindestens ein Verfügbarkeitstag erforderlich; die Einrichtung erlaubt höchstens 31. +- Kita-Anlage und Zuordnung des Gründers erfolgen in einer Datenbanktransaktion; erneute Einrichtung desselben Kontos wird abgefangen. +- Login mit E-Mail und Passwort, Rückleitung zu einer internen Zielseite und allgemeine Fehlermeldung bei falschen Zugangsdaten. +- Abmeldung über Desktop-Navigation und mobiles Menü. +- Nach zehn Fehlversuchen wird das betreffende Konto für 15 Minuten gesperrt; erfolgreicher Login setzt den Zähler zurück. +- JWT-basierte Sessions mit Benutzer-, Kita-, Familien- und Rolleninformationen. Rolle und Zuordnungen werden bei weiteren Session-Aufrufen mit der Datenbank abgeglichen. +- Geschützte Seiten leiten nicht angemeldete Personen zum Login und Gründer ohne Kita zur Einrichtung weiter. +- Datenschutzannahme wird mit Zeitpunkt und Versionskennung gespeichert. Die Sonderseite für fehlende Zustimmung verweist auf Support; sie enthält kein Formular zum Nachholen der Zustimmung. + +**Einschränkungen:** Kein Passwort-vergessen-/Passwort-ändern-Ablauf. Die Gründerregistrierung setzt `emailVerifiedAt` unmittelbar, ohne Bestätigungsmail. Ein allgemeines IP-Limit für Login oder Registrierung ist nicht angebunden; das Kontaktformular hat sein eigenes Limit. + +Quellen: [Registrierung](../src/app/register/actions.ts), [Einrichtung](../src/app/onboarding/actions.ts), [Login](../src/app/login/actions.ts), [Authentifizierung](../src/auth.ts), [Proxy](../src/proxy.ts), [Consent-Seite](../src/app/onboarding/consent/page.tsx). + +### 4.3 Dashboard und Navigation + +- Begrüßung mit Kita-Namen, Benutzernamen und aktuellem Datum. +- Kennzahlen zu Familien, aktiven Kindern, eingetragenen Notdienst-Verfügbarkeiten des Folgemonats und rechnerisch heute anwesenden Kindern. +- Direktes Melden einer Kinderabwesenheit und Übersicht der laufenden/bevorstehenden eigenen Abwesenheiten. +- Schnellzugriffe auf Familienverwaltung, Kalender, Notdienstplanung und Adressbuch. Die drei letztgenannten berücksichtigen die Modulschalter bei der Anzeige. +- Hinweisdialog bei unvollständigem Profil: Telefon, Adresse sowie gegebenenfalls Haushalt und Kinder. Fortschritt wird angezeigt; der Hinweis kann im lokalen Browser dauerhaft ausgeblendet werden. +- Rollenabhängige Seitenleiste und ein auf kleinen Bildschirmen aufklappbares Menü. + +**Einschränkungen:** Die Notdienstkennzahl zählt Verfügbarkeitseinträge, keine tatsächlich besetzten Plantage. Schnellzugriffe auf Verwaltung werden nicht vollständig nach Rolle ausgeblendet; die Zielseiten besitzen Rollenprüfungen. Die Notdienst-Badge `4` in der Seitenleiste ist fest hinterlegt. + +Quellen: [Dashboard](../src/app/dashboard/page.tsx), [Profilhinweis](../src/components/OnboardingDialog.tsx), [Dashboard-Rahmen](../src/components/dashboard/dashboard-shell.tsx). + +### 4.4 Profil, Haushalt und eigene Kinder + +- Anzeige von Name, E-Mail-Adresse, Rolle, Kita, Haushalt, angelegtem Konto und zugewiesenen festen Ämtern. +- Bearbeiten von Telefonnummer, Straße, Postleitzahl und Ort. +- Adressbuchfreigabe erteilen oder zurückziehen; Zeitpunkt der Freigabe wird gespeichert. +- Eigenen Haushalt anlegen, wenn noch keiner zugeordnet ist. +- Eigene Kinder anlegen, bearbeiten und löschen; Vor-/Nachname sowie optionales Geburtsdatum. +- Bei Eingabe eines Geburtsdatums: gültiges Datum, nicht in der Zukunft, höchstens sieben Jahre zurück. +- Datenänderungen an Kindern werden auf den eigenen Haushalt und die eigene Kita begrenzt. +- Eigenes Konto nach Bestätigung löschen. Bestehen aktuelle oder zukünftige veröffentlichte Notdienstpflichten des Haushalts, wird die Selbstlöschung abgelehnt. +- Beim letzten Benutzer eines Haushalts wird die Familie samt abhängigen Kindern gelöscht; bei weiteren Benutzern bleibt der Haushalt bestehen. + +**Einschränkungen:** Name, E-Mail und Rolle lassen sich im persönlichen Profil nicht ändern. Eine Sperre der Kinderlöschung bei bestehenden Notdienstpflichten ist in `deleteMyChild` nicht vorhanden, obwohl die Kontolöschung diese Pflichten prüft. + +Quellen: [Profilseite](../src/app/dashboard/profil/page.tsx), [Profilaktionen](../src/app/dashboard/profil/actions.ts). + +### 4.5 Familienverwaltung, Eltern-Einladungen und feste Ämter + +- Adminübersicht aller Familien mit Eltern und Kindern; Suche nach Familie, Eltern, E-Mail und Kindern. +- Umschalten zwischen Tabelle und Karten. +- Neue Familie mit einem oder zwei Elternteilen sowie einem bis zehn Kindern anlegen. +- Familienname, Namen/E-Mail-Adressen der Eltern und Kindernamen bearbeiten. +- Zweiten Elternteil ergänzen, neue Kinder hinzufügen und Kinder entfernen. Der Bearbeitungsablauf verlangt mindestens ein verbleibendes Kind und höchstens zwei Elternteile. +- Neue Elternkonten erhalten die Rolle `ELTERN` und einen Einladungslink zur Passwortvergabe mit sieben Tagen Gültigkeit. +- Aktivierung prüft Link, Ablaufdatum, Passwortlänge, Passwortbestätigung und Datenschutzannahme; optionales Adressbuch-Opt-in. +- Nach Aktivierung werden Passwort und Zustimmungen gespeichert, der Token gelöscht und die Person direkt angemeldet. +- Die Familienliste zeigt anhand der Kontoverifikation, ob eine Einladung noch offen ist. +- Feste Ämter mit Name und optionaler Beschreibung anlegen und löschen. +- Ein oder mehrere Ämter an einzelne Benutzer vergeben und Zuordnungen entfernen; doppelte identische Zuordnungen werden verhindert. +- Ämter sind im Profil und Adressbuch sichtbar. + +**Einschränkungen:** Kein regulärer Dialog zum erneuten Versand oder Widerruf einer Einladung. Einladungen werden im tatsächlichen Ablauf in `VerificationToken` gespeichert. Das separate Modell `Invitation` ist dafür nicht angebunden. Scheitert der Mailversand nach der Datenanlage, bleibt die Familie angelegt und eine Fehlermeldung wird zurückgegeben. Eine vollständige Familienlöschung durch Kita-Admins ist nicht angeboten. + +Quellen: [Familienaktionen](../src/app/dashboard/families/actions.ts), [Familienliste](../src/app/dashboard/families/_components/families-list-client.tsx), [Einladungsaktivierung](../src/app/invite/%5Btoken%5D/actions.ts), [Ämter](../src/app/dashboard/families/duty-actions.ts). + +### 4.6 Notdienst-Verfügbarkeiten für Eltern + +- Eingabe für den Folgemonat, bezogen auf aktive Kinder des eigenen Haushalts. +- Mindestzahl an ausgewählten Tagen entspricht Anzahl der Kinder × Kita-Einstellung. +- Bereits gespeicherte Haushaltseinträge werden geladen und können erneut gespeichert werden. +- Monatskalender mit Terminen, Kategorien, Veranstaltungsdetails und auswählbaren Verfügbarkeitstagen. +- Die Oberfläche lässt Montag bis Freitag zu und sperrt bestätigte Schließtage. +- Auswahlfortschritt und Rückmeldung bei zu wenigen Tagen. +- Die ausgewählten verschiedenen Tage werden beim Speichern reihum auf die angegebenen Kinder verteilt; bestehende Einträge dieser Kinder im Zielmonat werden ersetzt. +- Eingabefrist ist im Code fest: bis einschließlich 26. des aktuellen Monats, gesperrt ab dem 27. +- Ohne aktive eigene Kinder erscheint ein Hinweis mit Verweis zum Profil. + +**Einschränkungen:** Die Server Action prüft Datumsformat, Zielmonat, Mindestzahl und Haushaltszugehörigkeit, aber keine Wochenenden oder Schließtage. Die Frist ist nicht im Einstellungsdialog konfigurierbar. Abwesenheitsmeldungen werden bei den Verfügbarkeiten nicht automatisch berücksichtigt. + +Quellen: [Elternseite](../src/app/dashboard/notdienst/page.tsx), [Kalendereingabe](../src/app/dashboard/notdienst/notdienst-form.tsx), [Speichern](../src/app/dashboard/notdienst/actions.ts), [Fristregel](../src/lib/date-utils.ts). + +### 4.7 Notdienst-Planungszentrale + +- Admins und Koordinatoren können über Monat/Jahr navigieren; Vorgabe ist der Folgemonat. +- Übersicht von Haushalten, Kindern, Verfügbarkeitstagen, Haushalten mit/ohne Rückmeldung und Planstatus. +- Automatische Erzeugung eines monatlichen Entwurfs; ein bestehender Monatsplan verhindert erneute Generierung. +- Für jeden Montag bis Freitag wird aus verfügbaren Kindern ein Haushalt ausgewählt, der im bisherigen Monatsdurchlauf möglichst selten eingeteilt wurde. Bei Gleichstand kommt Zufall zum Einsatz; der Zähler startet jeden Monat neu. +- Tage ohne Verfügbarkeiten bleiben unbesetzt. +- Vorschau und manuelle Zuordnung zu einem aktiven Kind der Kita; Tage können auch geleert werden. +- Entwurf veröffentlichen; veröffentlichte Einteilungen erscheinen im Kalender. +- Veröffentlichte Pläne nachbearbeiten, solange der betreffende Tag nicht vergangen ist. +- Nach manuellen Änderungen eines veröffentlichten Plans werden E-Mails an betroffene Elternkonten der Rolle `ELTERN` angestoßen; betroffene offene Tauschanfragen werden ungültig. +- Entwurfsplan mit Bestätigungsdialog zurücksetzen und löschen. Veröffentlichte Pläne werden serverseitig vom Reset ausgeschlossen. +- Kita-Einstellungen für Mindestverfügbarkeiten und Erinnerungs-Vorlauf speichern. +- Manuell Erinnerungen an Haushalte versenden, die im ausgewählten Monat gar keinen Verfügbarkeitseintrag haben. +- „Als PDF exportieren“ öffnet den Browser-Druckdialog (`window.print()`); die PDF-Datei entsteht über die Druckfunktion des Browsers. + +**Einschränkungen:** Keine garantierte optimale oder über mehrere Monate ausgeglichene Verteilung. Die Generierung berücksichtigt keine Feiertage, Schließtage, Teamtage oder Kinderabwesenheiten. Ein Erinnerungs-Vorlauf wird gespeichert, aber kein zeitgesteuerter Job verwendet ihn. Haushalte mit zu wenigen Einträgen erhalten über diese Erinnerungsaktion keine Erinnerung, sobald wenigstens ein Eintrag vorliegt. Beim erstmaligen Veröffentlichen wird keine Zuteilungsmail versendet; die entsprechende E-Mail-Vorlage allein ist kein angebundener Versand. + +Quellen: [Planungsseite](../src/app/dashboard/notdienst/plan/page.tsx), [Planaktionen](../src/app/dashboard/notdienst/plan/actions.ts), [Planoberfläche](../src/app/dashboard/notdienst/plan/plan-view.tsx). + +### 4.8 Notdienst-Tausch zwischen Familien + +- Eltern können im Kalender einen fremden Notdienst anfragen und einen eigenen veröffentlichten Dienst zum Tausch anbieten; optionale Nachricht bis 500 Zeichen. +- Beide Dienste müssen derselben Kita angehören, veröffentlicht und heute oder zukünftig sein. Ein Tausch im eigenen Haushalt wird abgewiesen. +- Dienste mit wartender oder bestätigter Alarmierung sind vom Tausch ausgeschlossen. +- Bereits beteiligte Dienste mit offenen Tauschanfragen können keine weitere offene Anfrage erhalten. +- Der angefragte Haushalt erhält eine E-Mail und sieht die offene Anfrage im Kalender. +- Angefragter Haushalt kann annehmen oder ablehnen; anfragender Haushalt kann zurückziehen. +- Annahme vertauscht beide Kinder-Zuordnungen in einer Transaktion, speichert die Entscheidung und storniert weitere betroffene offene Anfragen. +- Annahme und Ablehnung lösen Benachrichtigungen an beide Familien aus; Rücknahme speichert den Status ohne eigenen Mailversand. +- Kalender und Notdienstansichten werden nach Änderungen neu validiert. + +**Einschränkungen:** Neue Anfragen sind auf die exakte Rolle `ELTERN` beschränkt; Eltern mit Admin-/Koordinatorrolle können sie nicht starten. Die Oberfläche zeigt offene Anfragen, keine vollständige abgeschlossene Tauschhistorie. Eine erneute Verfügbarkeitsprüfung für die getauschten Tage erfolgt nicht. + +Quellen: [Tauschaktionen](../src/app/dashboard/kalender/actions.ts), [Kalender und Anfragen](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Tauschdialog](../src/app/dashboard/kalender/_components/termin-list.tsx). + +### 4.9 Notdienst-Alarmierung + +Vorbereitet sind: + +- Server Action zum Auslösen eines Alarms durch Admin/Koordinator für eine Einteilung. +- Kita-Prüfung und Abweisung einer bereits alarmierten Einteilung. +- Eigene Alarmdatensätze und E-Mails für die Elternkonten der eingeteilten Familie mit Rolle `ELTERN`. +- Bestätigungsseite unter `/alert/[token]`; der Link erlaubt die Bestätigung ohne Login. +- Statuswechsel von `PENDING` auf `CONFIRMED` mit Zeitstempel; erneute Bestätigung wird abgewiesen. +- Vorbereitete Button-Komponente für das Auslösen. + +**Aktueller Stand:** `AlertButton` wird von keiner Seite eingebunden. Ein erreichbarer Auslöseablauf und eine laufende Statusübersicht für die Koordination fehlen damit in der aktuellen Oberfläche. Das Datenmodell kann eine erkrankte Fachkraft und Notizen speichern, aber die Aktion nimmt nur die Einteilungs-ID entgegen. Eine Auswahl der Fachkraft, Alarmstornierung, Eskalation oder zeitliche Token-Gültigkeit ist nicht angebunden. + +Quellen: [Alarmaktion](../src/app/dashboard/actions.ts), [ungebundener Alarmbutton](../src/app/dashboard/alert-button.tsx), [Bestätigung](../src/app/alert/%5Btoken%5D/actions.ts). + +### 4.10 Kalender, Terminanfragen und Mitbringlisten + +- Gemeinsamer Kalender für bestätigte Termine, eigene ausstehende Terminanfragen, geplante Elterndienste und veröffentlichte Notdienste. +- Listen-, Monats- und Agendaansicht; Monatsnavigation und Sprung zu heute. +- Tagesdialog mit Termindetails, Diensten und Mitbringlisten. +- Suche nach Titel/Beschreibung und bei Diensten nach Familie beziehungsweise Kind. +- Kategorienfilter und Umschaltung zwischen eigenen und allen Diensten; reguläre Kita-Termine bleiben auch im eigenen Filter sichtbar. +- Kategorien: Kita-Fest, Schließtag, Teamtag, Mitmach-Tag, Elternabend, Mitgliederversammlung, Elterncafé, interner/externer Geburtstag und Sonstiges. +- Terminanfrage mit Titel, Beschreibung, Kategorie, Anfang/Ende und Ganztagsangabe. +- Eigene Anfragen werden als ausstehend angezeigt; Admins/Koordinatoren können ausstehende Anfragen bestätigen oder mit Begründung ablehnen. +- Admins/Koordinatoren können bestätigte Termine direkt anlegen, bearbeiten und endgültig löschen. +- Datumsprüfung: Beginn nicht in der Vergangenheit, Ende nicht vor Beginn. +- Mitbringliste je Termin aktivieren/deaktivieren; Textbeiträge hinzufügen. +- Eigene Beiträge löschen; Admins/Koordinatoren können auch fremde Beiträge löschen. +- Verweise aus Dienst-/Notdienstterminen in die entsprechenden Verwaltungsseiten; Diensttausch über den Kalender. + +**Einschränkungen:** Ausstehende Anfragen blockieren keinen Zeitraum durch eine serverseitige Kollisionsprüfung. Eine Anfrage ist für andere Eltern nicht allgemein als reservierter Slot sichtbar. Mitbringbeiträge lassen sich nicht direkt bearbeiten, sondern nur löschen und neu anlegen. Es wurde kein Kalenderabo, ICS-Export oder Serientermin-Ablauf gefunden. + +Quellen: [Kalenderseite](../src/app/dashboard/kalender/page.tsx), [Kalenderansichten](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Terminaktionen](../src/app/dashboard/kalender/actions.ts), [Mitbringliste](../src/app/dashboard/kalender/_components/mitbringsel-list.tsx). + +### 4.11 Event-Anmeldungen und RSVP + +- Admins/Koordinatoren können Termine mit Teilnahmeabfrage, optionaler Rückmeldefrist und Hinweisen für Teilnehmende anlegen/bearbeiten. +- Verwaltungsseite listet Termine samt Status und zeigt pro Event Zusagen, Absagen und ausstehende Antworten. +- Kindbezogene Teilnehmerliste über alle aktiven Kinder der Kita. +- Server Action für Zu-/Absage eines eigenen aktiven Kindes, inklusive Prüfung von Kita, Haushalt, bestätigtem Termin und Frist. +- Fehlt eine eigene Frist, gilt der Eventbeginn als Rückmeldeschluss. Eine bestehende Antwort kann bis dahin überschrieben werden. +- Teilnehmerhinweise werden im Kalender bei Admin/Koordinator und bei einem Haushalt mit mindestens einer Zusage angezeigt. + +**Aktueller Stand:** Die Eltern-Eingabekomponente `RsvpActionCard` existiert, wird aber von keiner Seite verwendet. Eltern besitzen deshalb im aktuellen Seitenaufbau keinen eingebundenen Zu-/Absagedialog. Außerdem wird `participantInfo` bereits an den Kalender-Client übertragen und nur bei der Darstellung ausgeblendet; die Anzeigeprüfung ist keine serverseitige Zugriffsbeschränkung für diese Information. + +Quellen: [RSVP-Aktionen](../src/actions/rsvp.ts), [Event-Verwaltung](../src/app/dashboard/admin/kalender/admin-calendar-manager.tsx), [ungebundene Elternkomponente](../src/app/dashboard/rsvp-action-card.tsx), [Kalenderdaten](../src/app/dashboard/kalender/page.tsx). + +### 4.12 Krank- und Abwesenheitsmeldungen + +- Eigenes aktives Kind aus dem Haushalt auswählen und für einen Tag oder Zeitraum abmelden. +- Gründe: Krankheit, Urlaub und Sonstiges; optionale Notiz bis 500 Zeichen. +- Ende darf nicht vor dem Beginn liegen; sich überschneidende Meldungen desselben Kindes werden abgewiesen. +- Eigene laufende/bevorstehende Meldungen anzeigen und löschen. +- Tagesübersicht für Admin, Koordinator und Erzieherrolle mit Kind, Familie, Grund, Zeitraum und Notiz. +- Tagesnavigation zurück/vor/heute und freie Datumswahl. +- Grundfilter, Kennzahlen und Hinweise zu Wochenende, Schließtag/Teamtag sowie Tagen mehr als 30 Tage in der Zukunft. +- Abwesenheiten sind nicht als echte Anwesenheitserfassung mit Check-in/Check-out implementiert; „anwesend“ ist aus Kinderzahl minus Meldungen abgeleitet. + +**Einschränkungen:** Gruppen „Krippe“ und „Elementar“ werden aus dem ersten Zeichen des Kindernamens künstlich abgeleitet (`mockGroup`). Das Datenmodell hat keine echte Gruppenzuordnung. Die Anwesenheitszahl dieser Ansicht verwendet die gefilterten Fehlmeldungen bei weiterhin gesamter Kinderzahl und kann deshalb bei aktivem Filter irreführend sein. Eine bestehende Abwesenheit kann nur gelöscht und neu gemeldet, nicht direkt bearbeitet werden. + +Quellen: [Abwesenheitsaktionen](../src/actions/absences.ts), [Elternformular](../src/app/dashboard/absence-card.tsx), [Tagesübersicht](../src/app/dashboard/admin/abwesenheiten/page.tsx), [Filter](../src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx). + +### 4.13 Wöchentlicher Dienstplan + +- Separater Dienstplan für Haushalte, unabhängig von festen personenbezogenen Ämtern und Notdienst. +- Diensttypen mit Name/Beschreibung anlegen und löschen. +- Wochenraster mit wählbarem Beginn und Zeitraum; Standard zwölf Wochen, maximal 104 Wochen. +- Haushalt je Diensttyp/Kalenderwoche auswählen oder Zuordnung entfernen; Änderungen werden direkt gespeichert. +- Kita-Zugehörigkeit von Diensttyp und Familie wird geprüft. +- Einteilungen erscheinen im gemeinsamen Kalender. +- Manuelle Erinnerungsaktion für noch nicht erinnerte geplante Dienste, deren Beginn in der aktuellen Woche liegt. +- Versand an verifizierte Benutzer der Familie; erfolgreicher Versand wird mit `reminderSentAt` gespeichert. Erneute Zuordnung setzt diesen Wert zurück. + +**Einschränkungen:** Kein automatisch laufender Erinnerungsjob. Status `DONE` und `CANCELLED` existieren im Datenmodell, aber ein regulärer Abschluss-/Stornoablauf ist nicht eingebunden. Eine Änderung setzt den Dienst wieder auf `PLANNED`. Das Löschen eines Diensttyps löscht durch die Datenrelationen auch dessen Einteilungen. + +Quellen: [Dienstplanseite](../src/app/dashboard/admin/dienste/page.tsx), [Raster](../src/app/dashboard/admin/dienste/duty-planner.tsx), [Dienstaktionen](../src/app/dashboard/admin/dienste/actions.ts). + +### 4.14 Schwarzes Brett, E-Mail und Anhänge + +- Admins können eine Ankündigung mit Titel und Markdown-Inhalt erstellen; Vorschau ist im Formular vorhanden. +- PDF-, JPG- und PNG-Anhänge bis zehn MB je Datei. +- Speicherung der Anhangdateien auf dem Server und der Metadaten in der Datenbank. +- Abruf über geschützte API mit Prüfung auf angemeldeten Benutzer und dieselbe Kita; keine Auslieferung über ein öffentliches Uploadverzeichnis. +- Optionaler E-Mail-Versand an verifizierte Haushaltskonten, Erzieherkonten oder beide Gruppen. Die Auswahl betrifft E-Mail-Empfänger und wird nicht als Sichtbarkeitsgruppe der Meldung gespeichert. +- Adminseite zeigt die letzten acht veröffentlichten Meldungen mit Autor, Zeitpunkt und Anhanganzahl. +- Bei Dateischreibfehlern werden die neue Meldung und angefangene Dateien zurückgenommen; bei Mailfehlern bleibt die Meldung gespeichert und eine Warnung wird zurückgegeben. +- Lesestatusmodell und Aktion zum Markieren als gelesen sind vorhanden; `NewsTicker` enthält einen vorbereiteten Meldungsdialog. + +**Aktueller Stand:** `NewsTicker` ist nicht in das Dashboard oder eine andere Seite eingebunden. Eltern können neue Meldungen daher derzeit über den optionalen Mailversand erhalten, besitzen aber keine eingebundene Meldungsliste in der App. Eine Adminauswertung der Lesebestätigungen sowie Bearbeiten/Löschen von Meldungen sind nicht umgesetzt. + +Quellen: [Meldungsaktionen](../src/actions/announcements.ts), [Adminseite](../src/app/dashboard/admin/news/page.tsx), [Formular](../src/app/dashboard/admin/news/news-form.tsx), [ungebundener Ticker](../src/app/dashboard/news-ticker.tsx), [Anhang-API](../src/app/api/announcements/attachments/%5Bid%5D/route.ts). + +### 4.15 Adressbuch + +- Interne Übersicht der Haushalte, bei denen mindestens ein Benutzer der Kontaktfreigabe zugestimmt hat. +- Anzeige von Haushaltsname, Kindern, Elternnamen und festen Ämtern; alphabetische Gruppierung nach Haushaltsnamen. +- E-Mail, Telefon und Adresse werden nur für den jeweiligen Benutzer mit Opt-in geladen. +- Suche nach Familienname, Eltern-/Kindernamen oder sichtbarer E-Mail. +- Filter für Telefonnummer, Ämter und Haushalte mit Opt-in aller zugeordneten Benutzer. +- Detaildialog je Familie; E-Mail- und Telefonlinks starten den jeweiligen Mail-/Telefonhandler. +- Sichtbarkeitsdialog erklärt die Freigabe und führt zum eigenen Profil. +- Optionales Geburtsdatum eines Kindes ist im Detaildialog sichtbar. + +**Einschränkung:** Namen und Ämter eines zweiten Elternteils ohne Kontaktfreigabe sowie Kinderinformationen sind innerhalb eines sichtbaren Haushalts weiterhin sichtbar. Die Freigabe ist technisch kein Opt-in für sämtliche Haushaltsinformationen. + +Quellen: [Adressbuchdaten](../src/app/dashboard/adressbuch/page.tsx), [Adressbuchoberfläche](../src/app/dashboard/adressbuch/_components/directory-client.tsx). + +### 4.16 ErzieherInnen-Stammdaten + +- Admins können Fachkräfte mit Vor- und Nachname anlegen, bearbeiten und löschen. +- Aktivstatus wird gepflegt und dargestellt. +- Diese Datensätze sind keine Login-Konten; die separate Benutzerrolle `ERZIEHER` ist hiervon unabhängig. +- Referenz für Notdienstalarme im Datenmodell vorhanden, aber derzeit keine Fachkraftauswahl im Alarmauslöser. + +Quellen: [Erzieheraktionen](../src/app/dashboard/erzieher/actions.ts), [Erzieheroberfläche](../src/app/dashboard/erzieher/_components/erzieher-list.tsx). + +### 4.17 Systemweite Superadmin-Verwaltung + +- Übersicht aller Kitas mit Slug, Modulstatus, Benutzer-/Familien-/Kinderzahlen, Terminen, Abwesenheiten, Meldungen, offenen Einladungen und letztem Login. +- Hinweise auf Benutzer ohne Kita, Kontosperren, fehlende Datenschutzzustimmung und Familien ohne Benutzer. +- Kita-Diagnose mit Benutzerliste, Rollen, Familien, Verifikationsstatus, Consent, Fehlversuchen und Sperren; zusätzlich Zähler für Konten ohne Passwort und abgelaufene Einladungen. +- Diagnoseaufrufe werden im Audit-Protokoll gespeichert. +- Kita endgültig löschen nach exakter Namensbestätigung, mit kaskadierender Löschung abhängiger Datenbankdatensätze. +- Kita-gebundene Benutzer löschen nach Bestätigung ihrer E-Mail-Adresse; dabei zugehörige Verifikationstokens entfernen. +- Benutzer für weitere Logins sperren, entsperren oder Fehlversuche zurücksetzen. Sperrung setzt `lockedUntil` weit in die Zukunft; Entsperren löscht die Sperre und setzt den Zähler zurück. +- Eigene und andere Superadmin-Konten sowie Konten ohne Kita werden durch diese Support-Aktionen nicht verändert. +- Auditansicht zeigt die neuesten 200 Einträge samt Akteur, Ziel, Zeitpunkt, Aktion und Metadaten. Protokolle können auch nach Löschung der Zielobjekte bestehen bleiben. + +**Einschränkungen:** Offene und abgelaufene Einladungen werden über `Invitation` gezählt, während die tatsächlichen Familien-Einladungen `VerificationToken` nutzen. Eine Superadmin-Benutzerlöschung entfernt nicht automatisch einen nun leeren Haushalt. Eine Sperre wird bei der Anmeldung geprüft; das sofortige Beenden bereits bestehender Sessions ist nicht umgesetzt. + +Quellen: [Adminübersicht und Diagnose-Daten](../src/app/admin/_lib/data.ts), [Support-Aktionen](../src/app/admin/actions.ts), [Auditseite](../src/app/admin/audit/page.tsx). + +## 5. Datenmodell und technische Querschnittsfunktionen + +- PostgreSQL mit Prisma. Eine Kita ist ein Mandant; fachliche Daten sind über `kitaId` zugeordnet. +- Datenmodelle für Kitas, Benutzer, Haushalte, Kinder, Fachkräfte, feste Ämter, Diensttypen/-einteilungen, Abwesenheiten, Meldungen, Anhänge, Lesestatus, Termine, Teilnahme, Mitbringsel, Notdienstpläne/-verfügbarkeiten/-einteilungen/-alarme/-tausche sowie Auditprotokolle und Tokens. +- Eindeutigkeitsregeln unter anderem für E-Mail, Kita-Slug, Monatsplan, Einteilung je Plantag, Verfügbarkeit je Kind/Tag, Teilnahme je Event/Kind und Amt je Benutzer. +- Eingabeprüfung überwiegend mit Zod, Schreibabläufe teilweise als Datenbanktransaktionen, Aktualisierung von Seiten nach Änderungen. +- Gemeinsamer E-Mail-Versand über Resend mit Fehlerbehandlung. Ohne API-Key werden E-Mails nur in der Entwicklungsumgebung simuliert; diese Simulation bestätigt keine Zustellung. +- Vorlagen für Einladung, Alarm, Verfügbarkeitserinnerung, Einteilungsänderung, Diensttausch, Wochen-Diensterinnerung, News und Kontakt. Eine weitere Vorlage für erstmalige Notdienst-Zuteilung ist vorhanden, aber nicht an die Veröffentlichung angebunden. +- Rollen-, Kita- und Consent-Prüfungen über gemeinsame Helfer. Geschützte Dashboard-/Adminlayouts, Seiten und Aktionen setzen Zugriffsprüfungen ein; nicht jede Aktion verwendet denselben Consent-Helfer. +- Viele Datenbankrelationen löschen abhängige Datensätze mit. Das ist nicht gleichbedeutend mit vollständiger Bereinigung von Dateien, allgemeinen Verifikationstokens oder Audit-Snapshots. +- Der Hilfsmodus `TEST_MODE=true` liefert in `requireRole` und `requireKitaSession` künstliche Sessions und umgeht dort die regulären Prüfungen. Er ist nicht auf die Entwicklungsumgebung begrenzt. + +Quelle: [Schema](../prisma/schema.prisma), [Mail](../src/lib/mail.ts), [Auth-Helfer](../src/lib/auth-utils.ts). + +## 6. Seiten- und Schnittstellenverzeichnis + +| Route | Zweck/Zugriff | +| --- | --- | +| `/` | Öffentliche Startseite und Kontakt. | +| `/features/notdienst-planer` | Öffentliche Notdienst-Beschreibung. | +| `/features/kita-kalender` | Öffentliche Kalender-Beschreibung. | +| `/features/eltern-adressbuch` | Öffentliche Adressbuch-Beschreibung. | +| `/features/krankmeldung` | Öffentliche Abwesenheits-Beschreibung. | +| `/features/schwarzes-brett` | Öffentliche Meldungs-Beschreibung. | +| `/impressum`, `/datenschutz` | Öffentliche Informationsseiten mit noch offenen Platzhaltern. | +| `/register`, `/login` | Gründerregistrierung und Anmeldung. | +| `/onboarding` | Kita einrichten nach Anmeldung. | +| `/onboarding/consent` | Hinweis bei fehlender Datenschutzannahme. | +| `/invite/[token]` | Elternkonto über Einladungslink aktivieren. | +| `/alert/[token]` | Notdienstalarm über Link bestätigen. | +| `/forbidden` | Hinweis bei verweigertem Zugriff. | +| `/dashboard` | Kita-Übersicht und eigene Abwesenheiten. | +| `/dashboard/profil` | Eigener Haushalt, Kinder, Kontakte und Kontolöschung. | +| `/dashboard/notdienst` | Verfügbarkeiten; Eltern, Admin, Koordinator. | +| `/dashboard/notdienst/plan` | Planung; Admin, Koordinator. | +| `/dashboard/kalender` | Kita-Kalender und Diensttausch. | +| `/dashboard/adressbuch` | Internes Adressbuch. | +| `/dashboard/families` | Familien und feste Ämter; Admin. | +| `/dashboard/erzieher` | Fachkraft-Stammdaten; Admin. | +| `/dashboard/admin/dienste` | Wochen-Dienstplan; Kita-Admin. Die Aktion erlaubt zusätzlich Superadmin mit Kita-Kontext. | +| `/dashboard/admin/news` | Meldungen veröffentlichen; Admin. | +| `/dashboard/admin/kalender` | Termine und Event-Anmeldungen; Admin, Koordinator. | +| `/dashboard/admin/abwesenheiten` | Tagesübersicht; Admin, Koordinator, Erzieherrolle. | +| `/admin` | Systemweite Kita-Übersicht; Superadmin. | +| `/admin/kitas/[kitaId]` | Kita-Diagnose und Benutzersupport; Superadmin. | +| `/admin/audit` | Systemweites Audit-Protokoll; Superadmin. | +| `/api/auth/[...nextauth]` | Auth.js-Endpunkte. | +| `/api/announcements/attachments/[id]` | Geschützter Anhangdownload. | +| `/sitemap.xml`, `/robots.txt` | Suchmaschinensteuerung. | + +## 7. Betrieb, Entwicklung und Prüfstand + +- Stack laut `package.json`: Next.js 16.2.4, React 19.2.4, TypeScript, Prisma 6, Auth.js/NextAuth v5 beta, PostgreSQL, Tailwind CSS und Resend. +- Lokaler Entwicklungsstart führt vor Next.js automatisch `prisma db push` und den Seed aus. +- Seed legt Demonstrationsdaten mit mehreren Rollen und fachlichen Beispielen an; regulärer Seed ist bei bereits befüllter Datenbank zurückhaltend. Ein separater Reset-Modus existiert. +- Docker-Build und Produktionsstart sind vorhanden; Produktionsstart wendet das Schema über `prisma db push --skip-generate` an. Versionierte Prisma-Migrationen liegen nicht im geprüften Dateibestand. +- Lokale Compose-Datei stellt PostgreSQL bereit; Coolify-Compose enthält App, PostgreSQL, pgAdmin und persistente Datenbank-/Uploadvolumes. +- Umgebungsvariablen-Vorlage und Produktionscheckliste sind vorhanden; ihre tatsächliche Erfüllung wurde nicht auf einem Server geprüft. +- `UPLOAD_DIR` ist persistent vorgesehen. Die Dateihilfsfunktionen hängen noch `announcements` an diesen Pfad an; bei der aktuellen Compose-Konfiguration ergibt sich `/app/uploads/announcements/announcements`. +- Kein Testskript und keine automatisierte Testdateisuite im geprüften Repository gefunden. Demo-Seed und manueller Live-Testplan ersetzen keine automatisierten Tests. + +### Ausgeführte technische Prüfungen + +Die Prüfungen liefen auf dem unveränderten Ausgangscheckout mit den dort bereits installierten Abhängigkeiten. Es wurden keine Bibliotheken installiert und keine Builds oder Datenbankoperationen ausgeführt. + +| Prüfung | Ergebnis | +| --- | --- | +| `./node_modules/.bin/tsc --noEmit --incremental false` | Erfolgreich, Exit-Code 0. | +| `npm run lint` | Fehlgeschlagen, Exit-Code 1: zwei Fehler und neun Warnungen. | +| Quellenprüfung von Seiten, Aktionen, Komponenten und Schema | Durchführung für die oben beschriebenen Funktionsbereiche. | +| Aktueller Browser-/End-to-End-Test | Nicht durchgeführt. | +| Mailzustellung und produktives Deployment | Nicht geprüft. | + +Beide ESLint-Fehler betreffen `as any` im Testmodus von `src/lib/auth-utils.ts` (Zeilen 57 und 91). Die Warnungen betreffen ungenutzte Variablen/Imports im Seed, Kalender, Adressbuch und Abwesenheiten. Diese Bestandsaufnahme ändert keinen Anwendungscode. + +Der vorhandene [Live-Testplan](live-testplan.md) vom 17.05.2026 dokumentiert erfolgreiche Tests von Landingpage/Kontakt, Gründerregistrierung, Einrichtung, Dashboard, Familienanlage und Teilen des Login-Ablaufs. Eltern-Einladungsaktivierung und anschließende Bereinigung sind dort noch offen. Dies sind historische Einträge, keine aktuellen Testnachweise für den geprüften Commit. Die abgehakten Punkte in [UI-TODO.md](../UI-TODO.md) sind ebenfalls historische Angaben; viele der dort ehemals fehlenden Handler sind inzwischen im Code vorhanden. + +Quellen: [Paket und Skripte](../package.json), [Deployment](../deploy/README.md), [Produktionscheckliste](../deploy/production-checklist.md), [Seed](../prisma/seed.ts), [Coolify-Compose](../docker-compose.coolify.yml). + +## 8. Konkrete offene Punkte für die weitere Arbeit + +Die Reihenfolge ist ein Vorschlag aus der Bestandsaufnahme; es wurde nichts davon umgesetzt. + +| Reihenfolge | Thema | Konkreter nächster Schritt | +| --- | --- | --- | +| 1 | Fehlende Elternabläufe | NewsTicker und RSVP-Eingabe an eine erreichbare Seite anbinden; Daten dafür laden. | +| 2 | Notdienstalarm | Auslösebutton, Fachkraftauswahl und Statusanzeige in den aktuellen Seitenaufbau integrieren. | +| 3 | Moduldeaktivierung | Modulschalter in Navigation, Seiten und Server Actions einheitlich prüfen; spätere Einstellungen anbieten. | +| 4 | Notdienstregeln | Wochenenden/Schließtage serverseitig ausschließen und Generierung um Schließtage ergänzen; Abwesenheitsregel festlegen. | +| 5 | Erinnerungen | Automatische Ausführung ergänzen und unterscheiden, ob gar keine oder zu wenige Verfügbarkeiten vorliegen. | +| 6 | Erstveröffentlichung | Zuteilungsmails beim Veröffentlichen anbinden und Mailfehler sichtbar behandeln. | +| 7 | Einladungssupport | Tokenmodell und Superadmin-Zähler vereinheitlichen; erneuten Versand, Ablauf und Widerruf erreichbar machen. | +| 8 | Rechtevergabe | Regulären Ablauf für Koordinator-/Erzieherkonten und Rollenänderungen ergänzen. | +| 9 | Abwesenheitsgruppen | Echte Gruppenzuordnung einführen oder den künstlichen Gruppenfilter entfernen; Kennzahlen konsistent berechnen. | +| 10 | Buchungskonflikte | Fachliche Reservierungsregeln definieren und Überschneidungen serverseitig prüfen. | +| 11 | Selbstverwaltung | Passwort-Reset/-Änderung und direkte Bearbeitung von Abwesenheiten/Mitbringbeiträgen ergänzen. | +| 12 | Löschabläufe | Kinderlöschung bei bestehenden Verpflichtungen prüfen; Anhangdateien und Tokens bei Kita-/Kontolöschungen bereinigen; Audit-Aufbewahrung gesondert definieren. | +| 13 | Zugriffsprüfungen | Testmodus an Entwicklungsumgebung binden; Consent-/Session-Prüfungen und Teilnehmerhinweise serverseitig vereinheitlichen. | +| 14 | Technischer Zustand | Zwei ESLint-Fehler beheben, Migrationsablauf einführen und aussagekräftige Tests für die zentralen Abläufe ergänzen. | +| 15 | Aktuelle Abnahme | Rollenbasierte Browserprüfung, Mailzustellung, Elternaktivierung und Bereinigung anhand eines aktualisierten Testplans durchführen. | +| 16 | Öffentliche Texte | Platzhalter in Impressum/Datenschutz mit den tatsächlichen Betriebsangaben vervollständigen und Marketingversprechen mit dem implementierten Verhalten abgleichen. | + +## 9. Nicht als fertige Produktfunktion vorhanden + +Im geprüften Code wurden kein Abonnement-/Zahlungsablauf, keine Import-/Exportverwaltung für Familien, kein externer Kalenderabgleich, keine Push-/SMS-Alarmierung, keine echte Kita-Gruppenverwaltung und keine Zeiterfassung/Check-in-Funktion gefunden. Vorhandene Planungs- oder Marketingtexte sind dafür kein Umsetzungsnachweis. diff --git a/docs/PERSONALBEREICH.md b/docs/PERSONALBEREICH.md new file mode 100644 index 0000000..3cdcda3 --- /dev/null +++ b/docs/PERSONALBEREICH.md @@ -0,0 +1,97 @@ +# Personalbereich: Arbeitszeit, Urlaub und Zeitkonto + +Stand: 07.10.2026 · Branch `codex/personalbereich` + +## Einstieg und Zuständigkeiten + +Der Bereich liegt unter `/dashboard/personal`. Kita-Admins (einschließlich Vorstand mit Rolle `ADMIN`) verwalten alle Personalprofile ihrer Kita. ErzieherInnen bearbeiten eigene Zeiten und Anträge. Eltern und Koordination haben keinen Zugriff. Der interne Teamkalender ist nur für Admins und ErzieherInnen zugänglich. + +Bestehende ErzieherInnen-Stammdaten bleiben erhalten. Ein Admin öffnet beim Personalstamm das **Personalprofil** und hinterlegt: + +1. Beschäftigungsbeginn, optionales Ende, Beginn der Zeiterfassung und Anfangssaldo. +2. Ein datiertes Wochenmodell mit Netto-Sollstunden für Montag bis Sonntag; 0 bedeutet frei. +3. Jahresurlaub und ausdrücklich festgelegten Übertrag, individuell für die Arbeitstage dieser Person. +4. Einen passenden ErzieherInnen-Account. Bestehende verifizierte Accounts derselben Kita können verknüpft werden. Für neue Accounts wird ein persönlicher, einmal verwendbarer Aktivierungslink mit sieben Tagen Gültigkeit erzeugt. Ein neuer Link ersetzt den alten. +5. Die örtlichen Feiertage unter **Teamkalender**. Feiertage gelten separat von den Schließtagen des allgemeinen Kalenders. + +Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Aktivierungslinks werden nur dem berechtigten Admin angezeigt und persönlich weitergegeben. Automatischer E-Mail-Versand ist in dieser Umsetzung nicht angebunden. + +## Arbeitszeit und Überstunden + +Pro Tag sind mehrere Arbeitsblöcke mit Beginn, Ende und Pause möglich. Zeitblöcke dürfen nicht überlappen. Die Pause wird in Minuten vom Block abgezogen. Zeiten werden für heutige oder vergangene Tage innerhalb der Beschäftigung und ab Erfassungsbeginn erfasst. Blöcke über Mitternacht werden auf zwei Tage aufgeteilt. + +Die Tagesbilanz ist **Istzeit + Urlaub/Krankheit − Sollzeit**. Arbeit an einem freien Tag oder Feiertag zählt als zusätzliche Istzeit. Die Wochenansicht zeigt alle sieben Tage; in einer laufenden Woche fehlen zukünftige Arbeitszeiten noch, deshalb ist ihre Differenz vorläufig. + +Die Fachkraft reicht ihre Woche nach dem letzten vorgesehenen Arbeitstag ein. Offene Abwesenheiten im Zeitraum müssen zuerst geklärt sein. Der Admin prüft die einzelnen Zeiten, genehmigt die Woche oder gibt sie mit Rückmeldung zurück. Eingereichte und genehmigte Wochen sperren Zeitänderungen. Eigene Wochen und eigene Anträge können nicht selbst freigegeben werden. + +Beim Genehmigen wird die Tagesberechnung gespeichert. Nur genehmigte Wochen, der Anfangssaldo und wirksame manuelle Korrekturen zählen zum bestätigten Zeitkonto. Offene oder eingereichte Wochen wirken noch nicht auf dieses Konto. Eine Rückgabe entfernt die Wochenbuchung bis zur erneuten Freigabe. Fehler in manuellen Korrekturen werden durch eine begründete Gegenbuchung behoben. + +## Feste Wochentage und seltene Wechsel + +Wochenmodelle gelten ab ihrem Datum. Eine spätere Änderung überschreibt kein früheres Modell. Seltene Tausche werden über zwei Tagesausnahmen erfasst: der bisherige Arbeitstag erhält 0 Sollstunden, der Ersatztermin die vorgesehenen Stunden. + +Genehmigte Abwesenheiten sowie eingereichte oder genehmigte Wochen schützen ihren Zeitraum auch vor Änderungen an Modellen, Ausnahmen und Feiertagen. Betroffene Freigaben müssen vor einer solchen Änderung zurückgegeben bzw. storniert werden. Grunddaten und Anfangssaldo werden nach der ersten Erfassung geschützt; das Beschäftigungsende bleibt über einen eigenen Ablauf verwaltbar, sofern bestehende Zeiten und Anträge nicht verletzt werden. + +## Urlaub + +Urlaub ist ganztägig oder für einen einzelnen halben Tag möglich. Nur vorgesehene Arbeitstage verbrauchen Urlaub. Wochenenden sind nicht pauschal frei: Entscheidend sind Wochenmodell und Tagesausnahmen. Feiertage verbrauchen keinen Urlaub und erzeugen keine Sollzeit. Ein halber Urlaubstag schreibt die Hälfte der Tages-Sollzeit gut; bei ungerader Minutenzahl wird auf eine ganze Minute gerundet. + +Ein Jahreskonto führt Anspruch plus Übertrag, genommenen Urlaub, genehmigten geplanten Urlaub, offene Anträge und verfügbaren Rest. Offene Anträge werden bei weiteren Anträgen berücksichtigt. Abgelehnte und stornierte Anträge verbrauchen nichts. Zeiträume über den Jahreswechsel werden auf die jeweiligen Jahreskonten verteilt. Anspruch und Übertrag werden nicht automatisch anteilig berechnet oder zum Jahreswechsel übertragen. + +Offene Anträge können geändert oder zurückgezogen werden. Bei genehmigten Anträgen wird eine Stornierung beantragt. Bis zur Entscheidung bleibt die Abwesenheit gebucht. Krankheit hinterlegt der Admin; sie entlastet die Sollzeit, ohne Urlaub zu verbrauchen. KollegInnen sehen Krankheit im Teamkalender nur als **Abwesend**, ohne persönliche Notizen oder Kontodaten. + +## Freizeitausgleich + +Freizeitausgleich wird für einen einzelnen vorgesehenen Arbeitstag in Stunden beantragt. Das bestätigte verfügbare Guthaben muss ausreichen; weitere offene Anträge werden dabei berücksichtigt. + +Nach Genehmigung wird die Zeit reserviert. Freizeitausgleich erzeugt keine zusätzliche Istzeit oder Urlaubsgutschrift. Beispiel: 6 Stunden Soll, 5 Stunden Arbeit und 1 Stunde Freizeitausgleich ergeben eine Tagesbilanz von −1 Stunde. Mit der Wochenfreigabe wird diese Stunde im Zeitkonto gebucht und die Reservierung entfällt. Dadurch wird die Stunde genau einmal abgezogen. Bei offenen Stornierungen bleibt die Reservierung erhalten. + +## Hinweise und Nachvollziehbarkeit + +Neue Anträge und eingereichte Wochen erzeugen interne Hinweise für Kita-Admins. Entscheidungen und Rückgaben erzeugen Hinweise für die betroffene Fachkraft. Hinweise lassen sich als gelesen markieren; jede Person kann ausschließlich eigene Hinweise quittieren. Das Personalprofil zeigt die letzten 30 Änderungen. Im Datenmodell werden sämtliche Änderungen mit Zeitpunkt und handelnder User-ID gespeichert. + +Parallele Änderungen laufen in serialisierbaren Datenbanktransaktionen mit Wiederholung bei Schreibkonflikten. Rollen, Accountstatus, Eigentümer und Kita-Zugehörigkeit werden serverseitig geprüft. Das Löschen von Fachkräften mit Personalprofil wird verhindert; sie können auf inaktiv gesetzt werden. Bei Accountlöschung bleiben Personalaufzeichnungen ohne Accountverknüpfung erhalten. Die Löschung einer Kita entfernt auch ihren Personalbereich. + +## Einführung und Schema + +Das Projekt verwendet weiterhin seinen vorhandenen Ablauf mit `prisma db push`. Es gibt keine vollständige versionierte Prisma-Migrationshistorie. Die neuen Tabellen sind additiv; vorhandene Personen erhalten noch kein automatisch befülltes Beschäftigungsprofil. + +- Lokal: Abhängigkeiten installieren, Prisma-Client generieren, Schema gegen eine dafür vorgesehene Datenbank anwenden, App starten und Profile einrichten. +- Im vorhandenen Docker-Ablauf erzeugt `npm run build:prod` den Client; `scripts/start-production.sh` wendet das Schema vor App-Start an. +- [Prüfbares SQL](../deploy/sql/personalbereich.sql) beschreibt dieselbe additive Änderung gegen den dokumentierten Ausgangsstand. Es ist einmalig und nicht idempotent. Entweder den vorhandenen Schemaabgleich **oder** dieses SQL verwenden; das SQL nicht nach bereits erfolgtem Schemaabgleich erneut ausführen. + +Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht verändert. Alle DB- und Browserprüfungen liefen mit synthetischen Daten in einer separaten lokalen PostgreSQL-Datenbank. + +## Prüfungen + +- `npm run test:staff`: Berechnungstests für Teilzeit, halbe Tage, Feiertage, Jahreswechsel, Statuswechsel, Pausen, Snapshots und Freizeitausgleich. +- `npm run test:staff:integration`: echte Datenbankabläufe für Mandantentrennung, Rollen und Eigentümer, Anträge, Freigaben, parallele Änderungen, Sperren, Rückgaben, Aktivierungslinks und Beschäftigungsende. +- Integrationstests akzeptieren ausschließlich `127.0.0.1:55439/kita_staff_test`. Beispiel: `DATABASE_URL=postgresql://USER@127.0.0.1:55439/kita_staff_test npm run test:staff:integration`. Die leere Testdatenbank vorher mit dem aktuellen Schema einrichten. Tests bereinigen nur ihre eigens erzeugten Testkitas. +- Browserprüfung: Admin richtet Profil, 18-Stunden-Woche und 18 Urlaubstage ein; Erzieherin erfasst drei Blöcke mit Pausen, reicht eine 19-Stunden-Woche ein und beantragt einen Urlaubstag. Nach Adminfreigabe zeigt das Zeitkonto 11 Stunden (10 Stunden Anfangssaldo + 1 Stunde Wochenplus) und das Urlaubskonto 17 Tage. Der Urlaub erscheint im Teamkalender. + +Ergebnisse der abschließenden Prüfung: + +| Prüfung | Ergebnis | +| --- | --- | +| Berechnungstests | 9 bestanden. | +| Integrationstests | 12 bestanden; Testkitas danach bereinigt. | +| TypeScript | Erfolgreich. | +| ESLint für sämtliche geänderten Anwendungsteile und neue Tests | Erfolgreich, ohne Warnungen. | +| Projektweites ESLint | Die bereits vorhandenen zwei `as any`-Fehler in `src/lib/auth-utils.ts` und neun Warnungen bestehen weiterhin. | +| Endgültiger Produktionsbuild | Erfolgreich, einschließlich `/dashboard/personal`. | +| SQL-Skript gegen Ausgangsschema | Erfolgreich im isolierten Prüfschema; anschließend kein Unterschied zum Prisma-Schema. | +| Browser | Admin- und ErzieherInnen-Ablauf einschließlich Freigaben erfolgreich. Mobile Ansicht und Arbeitszeitdialog ohne Seitenüberlauf geprüft. | + +Die vom bestehenden Build gemeldete Warnung zu mehreren Lockfiles bzw. dem Turbopack-Arbeitsverzeichnis und die bestehende Prisma-Konfigurationswarnung sind unabhängig vom Personalbereich. Eine produktive Bereitstellung oder Mailzustellung wurde nicht durchgeführt. + +## E-Mail-Erweiterung zur gesonderten Freigabe + +Die automatische Freigabeprüfung hat neue ausgehende E-Mails mit Einladungstokens und Personaldaten abgelehnt, weil eine ausdrückliche Zustimmung für diese Daten und Ziele fehlt. Interne Hinweise und Aktivierungslinks sind umgesetzt. Für einen späteren Versand wäre folgender begrenzter Umfang vorgesehen: + +| Nachricht | Empfänger | Inhalt | +| --- | --- | --- | +| Accountaktivierung | Vom Kita-Admin eingetragene E-Mail-Adresse der Fachkraft | Kita, Name, persönlicher Aktivierungslink mit sieben Tagen Gültigkeit. | +| Antrag oder Arbeitswoche eingereicht | Account-Adressen der Kita-Admins derselben Kita | Name, Zeitraum und Hinweis auf Prüfung im geschützten Personalbereich. | +| Entscheidung / Rückgabe | Account-Adresse der betroffenen Fachkraft | Art, Zeitraum und Status mit Link in den geschützten Personalbereich. | + +Krankheitsdetails, private Notizen, Zeit- und Urlaubskontostände würden nicht per E-Mail übertragen. Hierfür wurde kein Live-Versandcode hinzugefügt und keine Nachricht verschickt. diff --git a/package.json b/package.json index 9f4b2c2..a5bd26b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "start": "next start", "start:prod": "next start", "lint": "eslint", + "test:staff": "tsx --test tests/staff-calculations.test.ts", + "test:staff:integration": "tsx --test tests/staff-service.test.ts", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44b650f..e76aff1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,8 @@ model Kita { notdienstAssignments NotdienstAssignment[] notdienstAlerts NotdienstAlert[] notdienstSwapRequests NotdienstSwapRequest[] + staffProfiles StaffProfile[] + staffHolidays StaffHoliday[] @@map("kitas") } @@ -163,9 +165,9 @@ model Family { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - users User[] - children Child[] - dutyAssignments DutyAssignment[] + users User[] + children Child[] + dutyAssignments DutyAssignment[] notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily") @@index([kitaId]) @@ -224,21 +226,23 @@ model User { // Relations dutyAssignments ParentDutyAssignment[] - notdienstAvailabilities NotdienstAvailability[] - notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent") - notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger") - notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator") - notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester") - notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder") - announcementsAuthored Announcement[] @relation("AnnouncementAuthor") - announcementReads AnnouncementRead[] + notdienstAvailabilities NotdienstAvailability[] + notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent") + notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger") + notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator") + notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester") + notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder") + announcementsAuthored Announcement[] @relation("AnnouncementAuthor") + announcementReads AnnouncementRead[] termineCreated Termin[] @relation("TerminCreator") termineApproved Termin[] @relation("TerminApprover") mitbringselItems MitbringselItem[] - invitationsCreated Invitation[] @relation("InvitationCreator") - auditLogsAuthored AuditLog[] @relation("AuditLogActor") + invitationsCreated Invitation[] @relation("InvitationCreator") + auditLogsAuthored AuditLog[] @relation("AuditLogActor") + staffProfile StaffProfile? + staffNotifications StaffNotification[] @@index([kitaId]) @@index([familyId]) @@ -295,11 +299,213 @@ model Educator { updatedAt DateTime @updatedAt notdienstAlerts NotdienstAlert[] + staffProfile StaffProfile? @@index([kitaId]) @@map("educators") } +// Personalbereich: Tage als UTC-Datum, Zeiten als Minuten, Anspruch in halben Tagen. +enum StaffRequestStatus { + PENDING + APPROVED + REJECTED + CANCEL_REQUESTED + CANCELLED +} + +enum StaffLeaveKind { + VACATION + TIME_OFF + SICKNESS +} + +enum StaffWeekStatus { + DRAFT + SUBMITTED + APPROVED +} + +model StaffProfile { + id String @id @default(cuid()) + kitaId String + kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) + educatorId String @unique + educator Educator @relation(fields: [educatorId], references: [id], onDelete: NoAction) + userId String? @unique + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + employmentStart DateTime @db.Date + employmentEnd DateTime? @db.Date + trackingStart DateTime @db.Date + openingMinutes Int @default(0) + createdAt DateTime @default(now()) + workPatterns StaffWorkPattern[] + dayOverrides StaffDayOverride[] + leaveAccounts StaffLeaveAccount[] + leaves StaffLeave[] + timeEntries StaffTimeEntry[] + weeks StaffWeek[] + adjustments StaffAdjustment[] + history StaffHistory[] + notifications StaffNotification[] + + @@index([kitaId]) + @@map("staff_profiles") +} + +model StaffWorkPattern { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + effectiveFrom DateTime @db.Date + // Montag bis Sonntag; 0 bedeutet arbeitsfrei. + dailyMinutes Int[] + + @@unique([profileId, effectiveFrom]) + @@map("staff_work_patterns") +} + +model StaffDayOverride { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + date DateTime @db.Date + minutes Int + reason String + + @@unique([profileId, date]) + @@map("staff_day_overrides") +} + +model StaffHoliday { + id String @id @default(cuid()) + kitaId String + kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade) + date DateTime @db.Date + name String + + @@unique([kitaId, date]) + @@map("staff_holidays") +} + +model StaffLeaveAccount { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + year Int + entitlementUnits Int + carryUnits Int @default(0) + + @@unique([profileId, year]) + @@map("staff_leave_accounts") +} + +model StaffLeave { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + kind StaffLeaveKind + status StaffRequestStatus @default(PENDING) + startDate DateTime @db.Date + endDate DateTime @db.Date + halfDay Boolean @default(false) + minutes Int? + note String? + decisionNote String? + requestedById String + reviewedById String? + reviewedAt DateTime? + createdAt DateTime @default(now()) + days StaffLeaveDay[] + + @@index([profileId, status, startDate]) + @@map("staff_leaves") +} + +model StaffLeaveDay { + id String @id @default(cuid()) + leaveId String + leave StaffLeave @relation(fields: [leaveId], references: [id], onDelete: Cascade) + date DateTime @db.Date + vacationUnits Int @default(0) + creditMinutes Int + + @@unique([leaveId, date]) + @@map("staff_leave_days") +} + +model StaffTimeEntry { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + date DateTime @db.Date + startMinute Int + endMinute Int + breakMinutes Int @default(0) + note String? + + @@index([profileId, date]) + @@map("staff_time_entries") +} + +model StaffWeek { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + weekStart DateTime @db.Date + status StaffWeekStatus @default(DRAFT) + submittedAt DateTime? + reviewedAt DateTime? + reviewedById String? + decisionNote String? + // Snapshot von Soll-, Ist- und Abwesenheitsminuten bei Genehmigung. + dailySnapshot Json? + + @@unique([profileId, weekStart]) + @@map("staff_weeks") +} + +model StaffAdjustment { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + date DateTime @db.Date + minutes Int + reason String + actorId String + createdAt DateTime @default(now()) + + @@index([profileId, date]) + @@map("staff_adjustments") +} + +model StaffHistory { + id String @id @default(cuid()) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + actorId String + action String + details String + createdAt DateTime @default(now()) + + @@index([profileId, createdAt]) + @@map("staff_history") +} + +model StaffNotification { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + profileId String + profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + message String + readAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId, readAt, createdAt]) + @@map("staff_notifications") +} + // ===================================================================== // PARENT DUTIES (Feste Ämter / Elterndienste, Modul 3) // ===================================================================== @@ -657,7 +863,7 @@ model NotdienstAssignment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - alerts NotdienstAlert[] + alerts NotdienstAlert[] swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment") swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment") @@ -685,11 +891,11 @@ model NotdienstSwapRequest { offeredAssignmentId String offeredAssignment NotdienstAssignment @relation("NotdienstSwapOfferedAssignment", fields: [offeredAssignmentId], references: [id], onDelete: Cascade) - status NotdienstSwapStatus @default(PENDING) + status NotdienstSwapStatus @default(PENDING) message String? respondedById String? - respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull) + respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull) respondedAt DateTime? createdAt DateTime @default(now()) @@ -756,13 +962,13 @@ model AuditLog { actorUser User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull) actorEmail String - action String - targetType AuditTargetType - targetId String - targetLabel String + action String + targetType AuditTargetType + targetId String + targetLabel String targetKitaId String? - metadata Json? + metadata Json? createdAt DateTime @default(now()) @@index([actorUserId]) diff --git a/src/app/dashboard/erzieher/_components/erzieher-list.tsx b/src/app/dashboard/erzieher/_components/erzieher-list.tsx index 14d2b99..2f50ce7 100644 --- a/src/app/dashboard/erzieher/_components/erzieher-list.tsx +++ b/src/app/dashboard/erzieher/_components/erzieher-list.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useTransition } from "react"; +import Link from "next/link"; import { Plus, Pencil, Search, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -40,7 +41,9 @@ type EducatorDto = { export function ErzieherList({ educators }: { educators: EducatorDto[] }) { const [openCreate, setOpenCreate] = useState(false); - const [editingEducator, setEditingEducator] = useState(null); + const [editingEducator, setEditingEducator] = useState( + null, + ); const [educatorToDelete, setEducatorToDelete] = useState( null, ); @@ -132,8 +135,16 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { - - + + @@ -153,67 +164,88 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { - - - - Vorname - Nachname - Status - Aktionen - - - - {educators.length === 0 ? ( +
+ - -
-
- -
-

- Keine Einträge gefunden -

-

- Lege die erste Person im Team an. -

-
-
+ Vorname + Nachname + Status + Aktionen
- ) : ( - educators.map((ed) => ( - - {ed.firstName} - {ed.lastName} - - - {ed.active ? "Aktiv" : "Inaktiv"} - - - - - +
+ + {educators.length === 0 ? ( + + +
+
+ +
+

+ Keine Einträge gefunden +

+

+ Lege die erste Person im Team an. +

+
- )) - )} -
-
+ ) : ( + educators.map((ed) => ( + + + {ed.firstName} + + {ed.lastName} + + + {ed.active ? "Aktiv" : "Inaktiv"} + + + + + + + + + )) + )} + +
{/* Edit Dialog */} - !open && setEditingEducator(null)}> + !open && setEditingEducator(null)} + >
@@ -223,21 +255,43 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
- +
- +
- +
)} - - + +
diff --git a/src/app/dashboard/erzieher/actions.ts b/src/app/dashboard/erzieher/actions.ts index ceba89d..defd518 100644 --- a/src/app/dashboard/erzieher/actions.ts +++ b/src/app/dashboard/erzieher/actions.ts @@ -19,12 +19,12 @@ export async function createEducator(rawPayload: unknown) { if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } - + const parsed = educatorSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } - + try { await prisma.educator.create({ data: { @@ -34,7 +34,7 @@ export async function createEducator(rawPayload: unknown) { active: parsed.data.active, }, }); - + revalidatePath("/dashboard/erzieher"); return { success: true }; } catch (error) { @@ -49,12 +49,12 @@ export async function updateEducator(id: string, rawPayload: unknown) { if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } - + const parsed = educatorSchema.safeParse(rawPayload); if (!parsed.success) { return { error: "Ungültige Eingabedaten." }; } - + try { await prisma.educator.update({ where: { @@ -67,7 +67,7 @@ export async function updateEducator(id: string, rawPayload: unknown) { active: parsed.data.active, }, }); - + revalidatePath("/dashboard/erzieher"); return { success: true }; } catch (error) { @@ -82,15 +82,23 @@ export async function deleteEducator(id: string) { if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } - + try { + const profile = await prisma.staffProfile.findFirst({ + where: { educatorId: id, kitaId }, + }); + if (profile) + return { + error: + "Diese Fachkraft hat ein Personalprofil. Bitte auf inaktiv setzen, damit Zeiten und Konten erhalten bleiben.", + }; await prisma.educator.delete({ where: { id, kitaId, }, }); - + revalidatePath("/dashboard/erzieher"); return { success: true }; } catch (error) { diff --git a/src/app/dashboard/erzieher/page.tsx b/src/app/dashboard/erzieher/page.tsx index fa117ef..22f41fb 100644 --- a/src/app/dashboard/erzieher/page.tsx +++ b/src/app/dashboard/erzieher/page.tsx @@ -36,8 +36,8 @@ export default async function ErzieherPage() {

- Stammdaten des Kita-Personals für interne Planung und spätere - Notdienst-Alarmierung. + Stammdaten des Teams und Zugang zu Beschäftigungsprofilen, + Urlaubsansprüchen und Arbeitszeitmodellen.

diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 09e692d..d402ab2 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -191,8 +191,7 @@ export default async function DashboardPage() {

Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier - siehst du auf einen Blick, was bei {kita.name} ansteht und kannst - dein Kind schnell für Abwesenheiten melden. + siehst du auf einen Blick, was bei {kita.name} ansteht.

@@ -203,6 +202,14 @@ export default async function DashboardPage() {
+ {session.user.role === UserRole.ERZIEHER && ( + + )} {canReportAbsences ? (
- +
- + {session.user.role === UserRole.ADMIN && ( + + )} + {session.user.role === UserRole.ADMIN && ( + + )} {kita.terminModuleEnabled ? ( ) : null} - {kita.notdienstModuleEnabled ? ( + {kita.notdienstModuleEnabled && + [UserRole.ADMIN, UserRole.KOORDINATOR].includes( + session.user.role as "ADMIN" | "KOORDINATOR", + ) ? ( , +): Promise { + const actor = await requireStaffActor(); + try { + const result = await executeStaffCommand(actor, { command, data }); + revalidatePath("/dashboard/personal"); + revalidatePath("/dashboard/profil"); + revalidatePath("/dashboard/erzieher"); + revalidatePath("/dashboard"); + // Returned only to the authenticated admin who generated this single-use link. + return { + success: true, + ...(result.invite + ? { invitePath: `/invite/${result.invite.token}` } + : {}), + }; + } catch (error) { + if (error instanceof z.ZodError) + return { + error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.", + }; + if ( + error instanceof Error && + !error.message.includes("prisma") && + !error.message.includes("Invalid `") + ) + return { error: error.message }; + return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." }; + } +} diff --git a/src/app/dashboard/personal/forms.tsx b/src/app/dashboard/personal/forms.tsx new file mode 100644 index 0000000..5c92d92 --- /dev/null +++ b/src/app/dashboard/personal/forms.tsx @@ -0,0 +1,757 @@ +"use client"; + +import { useId, useState, useTransition, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DAY_NAMES, + clockMinute, + minuteClock, + todayKey, +} from "@/lib/staff/calculations"; +import { staffAction, type StaffActionResult } from "./actions"; + +const control = + "w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]"; +export function Field({ + label, + name, + type = "text", + value, + required = true, + min, + max, + step, +}: { + label: string; + name: string; + type?: string; + value?: string | number; + required?: boolean; + min?: string | number; + max?: string | number; + step?: string | number; +}) { + const id = useId(); + return ( +
+ + +
+ ); +} +function transform(command: string, form: FormData) { + const text = (key: string) => String(form.get(key) ?? ""); + const num = (key: string) => Number(text(key)); + const hours = (key: string) => Math.round(num(key) * 60); + switch (command) { + case "setup": + return { + educatorId: text("educatorId"), + employmentStart: text("employmentStart"), + employmentEnd: text("employmentEnd") || null, + trackingStart: text("trackingStart"), + openingMinutes: hours("openingHours"), + reason: text("reason"), + }; + case "invite": + return { email: text("email") }; + case "employmentEnd": + return { + employmentEnd: text("employmentEnd") || null, + reason: text("reason"), + }; + case "pattern": + return { + effectiveFrom: text("effectiveFrom"), + dailyMinutes: DAY_NAMES.map((_, i) => hours(`day${i}`)), + reason: text("reason"), + }; + case "account": + return { + year: num("year"), + entitlementUnits: Math.round(num("entitlement") * 2), + carryUnits: Math.round(num("carry") * 2), + reason: text("reason"), + }; + case "override": + return { + date: text("date"), + minutes: hours("hours"), + reason: text("reason"), + }; + case "adjust": + return { + date: text("date"), + minutes: hours("hours"), + reason: text("reason"), + }; + case "holiday": + return { date: text("date"), name: text("name") }; + case "time": + return { + date: text("date"), + startMinute: clockMinute(text("start")), + endMinute: clockMinute(text("end")), + breakMinutes: num("breakMinutes"), + note: text("note"), + }; + case "leave": + return { + kind: text("kind"), + startDate: text("startDate"), + endDate: text("endDate"), + halfDay: form.get("halfDay") === "on", + ...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}), + note: text("note"), + }; + case "leaveReview": + case "weekReview": + return { reason: text("reason") }; + default: + return {}; + } +} +function StaffForm({ + command, + profileId, + extra = {}, + children, + label = "Speichern", + onSuccess, +}: { + command: string; + profileId?: string; + extra?: Record; + children: ReactNode; + label?: string; + onSuccess?: (result: StaffActionResult) => void; +}) { + const [pending, start] = useTransition(); + const [error, setError] = useState(""); + const router = useRouter(); + return ( +
{ + event.preventDefault(); + const form = new FormData(event.currentTarget); + setError(""); + start(async () => { + try { + const result = await staffAction(command, { + ...transform(command, form), + ...extra, + ...(profileId ? { profileId } : {}), + }); + if (result.error) { + setError(result.error); + return; + } + toast.success("Gespeichert."); + onSuccess?.(result); + router.refresh(); + } catch (error) { + setError( + error instanceof Error + ? error.message + : "Speichern fehlgeschlagen.", + ); + } + }); + }} + > +
+ {children} +
+ +
+
+ {error && ( +

+ {error} +

+ )} +
+ ); +} +export function CommandButton({ + command, + profileId, + data, + children, + variant = "outline", +}: { + command: string; + profileId: string; + data: Record; + children: ReactNode; + variant?: "outline" | "ghost" | "default"; +}) { + const [pending, start] = useTransition(); + const router = useRouter(); + return ( + + ); +} +export function ReviewButton({ + command, + profileId, + data, + label, + approve, +}: { + command: "leaveReview" | "weekReview"; + profileId: string; + data: Record; + label: string; + approve: boolean; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + + + {label} + + Die Entscheidung wird im Personalverlauf dokumentiert. + + + setOpen(false)} + > + + + + + ); +} +export function SetupForm({ + educatorId, + values, +}: { + educatorId: string; + values?: { + employmentStart: string; + employmentEnd: string | null; + trackingStart: string; + openingMinutes: number; + }; +}) { + return ( + + +
+ + + + +
+ +
+ ); +} +export function TimeForm({ + profileId, + initial, +}: { + profileId: string; + initial?: { + id: string; + date: string; + startMinute: number; + endMinute: number; + breakMinutes: number; + note: string | null; + }; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + + + + {initial ? "Arbeitszeit bearbeiten" : "Arbeitszeit erfassen"} + + + Erfasse Beginn, Ende und die gesamte Pause dieses Arbeitsblocks. + Weitere Blöcke am selben Tag sind möglich. + + + setOpen(false)} + > + +
+ + +
+ + +
+
+
+ ); +} +export function LeaveForm({ + profileId, + admin, + initial, +}: { + profileId: string; + admin: boolean; + initial?: { + id: string; + kind: string; + startDate: string; + endDate: string; + halfDay: boolean; + minutes: number | null; + note: string | null; + }; +}) { + const [open, setOpen] = useState(false); + const [kind, setKind] = useState(initial?.kind ?? "VACATION"); + const id = useId(); + return ( + + + + + + + + {initial ? "Antrag ändern" : "Abwesenheit beantragen"} + + + Urlaub verbraucht nur deine vorgesehenen Arbeitstage. + Freizeitausgleich wird in Stunden für einen einzelnen Tag beantragt. + + + setOpen(false)} + > +
+ + +
+
+ + +
+ {kind === "VACATION" && ( + + )} + {kind === "TIME_OFF" && ( + + )} + +

+ Der Antrag wird anhand deines Wochenmodells, der Tagesausnahmen und + der Feiertage geprüft. Ein halber Tag entspricht der Hälfte der + Sollzeit. +

+
+
+
+ ); +} +function SettingsSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+ {title} +
{children}
+
+ ); +} +export function SettingsPanel({ + profileId, + educatorId, + employment, + email, + verified, + pattern, + year, + account, +}: { + profileId: string; + educatorId: string; + employment: { + employmentStart: string; + employmentEnd: string | null; + trackingStart: string; + openingMinutes: number; + }; + email?: string; + verified: boolean; + pattern?: { effectiveFrom: string; dailyMinutes: number[] }; + year: number; + account?: { entitlementUnits: number; carryUnits: number }; +}) { + const [invitePath, setInvitePath] = useState(""); + return ( +
+ + {verified ? ( +

Aktives Konto: {email}

+ ) : ( + setInvitePath(r.invitePath ?? "")} + > + +

+ Erstellt oder verknüpft einen ErzieherInnen-Account. Der + persönliche Link ist sieben Tage gültig. Ein neuer Link ersetzt + den vorherigen. +

+
+ )} + {invitePath && ( +
+ + + +

+ Bitte persönlich an die Fachkraft weitergeben. Es wird keine + E-Mail versendet. +

+
+ )} +
+ + + +
+ {DAY_NAMES.map((day, i) => ( + + ))} +
+ +

+ 0 Stunden = frei. Stunden sind Nettozeiten ohne Pause. Änderungen + gelten ab dem gewählten Datum; ältere Modelle bleiben erhalten. +

+
+
+ + + +
+ + +
+ +

+ Anspruch individuell für die Arbeitstage dieser Person hinterlegen. + Der Übertrag wird ausdrücklich festgelegt. +

+
+
+ + + + + +

+ Bei einem Tausch beide Tage erfassen: den ursprünglichen Tag auf 0 + setzen und den neuen Arbeitstag mit Sollstunden hinterlegen. +

+
+
+ + + + + +

+ Korrekturen bleiben im Verlauf erhalten. Falsche Buchungen durch + eine Gegenbuchung berichtigen. +

+
+
+ + +

+ Die Grunddaten sind geschützt, sobald Zeiten, Wochen oder Anträge + erfasst wurden. +

+
+ + + + + + +
+ ); +} +export function HolidayForm() { + return ( + +
+ + +
+
+ ); +} diff --git a/src/app/dashboard/personal/page.tsx b/src/app/dashboard/personal/page.tsx new file mode 100644 index 0000000..f0b9900 --- /dev/null +++ b/src/app/dashboard/personal/page.tsx @@ -0,0 +1,1244 @@ +import Link from "next/link"; +import { UserRole } from "@prisma/client"; +import type { ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Stat } from "@/components/ui/stat"; +import { prisma } from "@/lib/prisma"; +import { requireStaffActor } from "@/lib/staff/auth"; +import { getStaffProfile } from "@/lib/staff/service"; +import { + DAY_NAMES, + KIND_LABELS, + STATUS_LABELS, + addDays, + calculateWeek, + dateKey, + dateOnly, + dateSchema, + datesBetween, + formatMinutes, + getTimeBalance, + minuteClock, + readSnapshot, + shortDate, + todayKey, + vacationBalance, + weekStart, +} from "@/lib/staff/calculations"; +import { + CommandButton, + HolidayForm, + LeaveForm, + ReviewButton, + SettingsPanel, + SetupForm, + TimeForm, +} from "./forms"; + +export const metadata = { title: "Personal & Arbeitszeit · Kita-Planer" }; +const inputClass = + "rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm"; +function Panel({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: ReactNode; +}) { + return ( + + + {title} + {hint &&

{hint}

} +
+ {children} +
+ ); +} +function Empty({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} +export default async function PersonalPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const actor = await requireStaffActor(); + const params = await searchParams; + const admin = actor.role === UserRole.ADMIN; + const today = todayKey(); + const pick = (key: string) => + typeof params[key] === "string" ? (params[key] as string) : ""; + const yearInput = Number(pick("year")); + const year = + Number.isInteger(yearInput) && yearInput >= 2000 && yearInput <= 2100 + ? yearInput + : Number(today.slice(0, 4)); + const requestedMonth = pick("month"); + const month = + /^\d{4}-(0[1-9]|1[0-2])$/.test(requestedMonth) && + dateSchema.safeParse(`${requestedMonth}-01`).success + ? requestedMonth + : today.slice(0, 7); + const monthStart = `${month}-01`; + const monthEndDate = dateOnly(monthStart); + monthEndDate.setUTCMonth(monthEndDate.getUTCMonth() + 1); + monthEndDate.setUTCDate(0); + const monthEnd = dateKey(monthEndDate); + const start = weekStart( + dateSchema.safeParse(pick("week")).success ? pick("week") : today, + ); + const end = addDays(start, 6); + const tabs = [ + ...(admin ? [{ key: "team", label: "Team & Freigaben" }] : []), + { key: "zeit", label: "Arbeitszeit" }, + { key: "urlaub", label: "Urlaub & Zeitkonto" }, + { key: "kalender", label: "Teamkalender" }, + { + key: "verwaltung", + label: admin ? "Personalprofil" : "Mein Arbeitsmodell", + }, + ]; + const tab = tabs.some((t) => t.key === pick("tab")) + ? pick("tab") + : admin + ? "team" + : "zeit"; + const [ + educators, + holidays, + notifications, + calendarLeaves, + pendingLeaves, + pendingWeeks, + ] = await Promise.all([ + admin + ? prisma.educator.findMany({ + where: { kitaId: actor.kitaId }, + orderBy: [{ lastName: "asc" }, { firstName: "asc" }], + include: { + staffProfile: { + select: { + id: true, + userId: true, + workPatterns: true, + leaveAccounts: true, + }, + }, + }, + }) + : Promise.resolve([]), + prisma.staffHoliday.findMany({ + where: { kitaId: actor.kitaId }, + orderBy: { date: "asc" }, + }), + prisma.staffNotification.findMany({ + where: { + userId: actor.id, + profile: { kitaId: actor.kitaId }, + readAt: null, + }, + orderBy: { createdAt: "desc" }, + take: 20, + }), + tab === "kalender" + ? prisma.staffLeave.findMany({ + where: { + profile: { kitaId: actor.kitaId }, + status: { in: ["APPROVED", "CANCEL_REQUESTED"] }, + startDate: { lte: dateOnly(monthEnd) }, + endDate: { gte: dateOnly(monthStart) }, + }, + select: { + id: true, + kind: true, + halfDay: true, + days: { select: { date: true, creditMinutes: true } }, + profile: { + select: { + educator: { select: { firstName: true, lastName: true } }, + }, + }, + }, + }) + : Promise.resolve([]), + admin + ? prisma.staffLeave.findMany({ + where: { + profile: { kitaId: actor.kitaId }, + status: { in: ["PENDING", "CANCEL_REQUESTED"] }, + }, + include: { + profile: { select: { educator: true, userId: true } }, + days: true, + }, + orderBy: { createdAt: "asc" }, + }) + : Promise.resolve([]), + admin + ? prisma.staffWeek.findMany({ + where: { profile: { kitaId: actor.kitaId }, status: "SUBMITTED" }, + include: { profile: { select: { educator: true, userId: true } } }, + orderBy: { weekStart: "asc" }, + }) + : Promise.resolve([]), + ]); + const selectedEducator = + educators.find((e) => e.id === pick("educator")) ?? + educators.find((e) => e.staffProfile?.userId === actor.id) ?? + educators.find((e) => e.active) ?? + educators[0]; + const profile = + admin && !selectedEducator?.staffProfile + ? null + : await getStaffProfile( + actor, + admin ? selectedEducator?.staffProfile?.id : undefined, + ); + const url = (values: Record) => { + const q = new URLSearchParams({ + tab, + year: String(year), + month, + week: start, + ...(selectedEducator ? { educator: selectedEducator.id } : {}), + ...values, + }); + return `/dashboard/personal?${q}`; + }; + const holidaySet = new Set(holidays.map((h) => dateKey(h.date))); + const account = profile?.leaveAccounts.find((a) => a.year === year); + const vacation = + account && profile ? vacationBalance(account, profile.leaves) : null; + const time = profile + ? getTimeBalance( + profile, + profile.weeks, + profile.adjustments, + profile.leaves, + ) + : null; + const sheet = profile?.weeks.find((w) => dateKey(w.weekStart) === start); + const days = profile + ? sheet?.status === "APPROVED" + ? readSnapshot(sheet.dailySnapshot) + : calculateWeek( + profile, + holidaySet, + start, + profile.timeEntries, + profile.leaves, + ) + : []; + const editable = !sheet || sheet.status === "DRAFT"; + const weekEntries = + profile?.timeEntries.filter( + (e) => dateKey(e.date) >= start && dateKey(e.date) <= end, + ) ?? []; + const totals = days.reduce( + (s, d) => ({ + expected: s.expected + d.expected, + worked: s.worked + d.worked, + credit: s.credit + d.credit, + balance: s.balance + d.balance, + }), + { expected: 0, worked: 0, credit: 0, balance: 0 }, + ); + const currentPattern = profile?.workPatterns + .filter((p) => dateKey(p.effectiveFrom) <= today) + .at(-1); + return ( +
+
+
+

+ TEAM · PERSONALBEREICH +

+

+ {admin ? "Personal &" : "Meine"}{" "} + + Arbeitszeit + +

+

+ Feste Arbeitstage, erfasste Stunden und Abwesenheiten an einem Ort. +

+
+ {profile && ( +
+ + +
+ )} +
+ {notifications.length > 0 && ( +
+ + {notifications.length} neue Hinweise + +
+ {notifications.map((n) => ( +
+

{n.message}

+ + Gelesen + +
+ ))} +
+
+ )} + {admin && ( +
+ + + + + +
+ )} + + {tab === "team" && admin ? ( +
+
+ e.staffProfile).length} + hint={`${educators.length} Personen im Personalstamm`} + /> + + +
+ +
+ {educators.length === 0 && ( + + Bitte zuerst eine Fachkraft im Personalstamm anlegen. + + )} + {educators.map((e) => ( + + + {e.firstName} {e.lastName} + + + {!e.staffProfile + ? "Profil einrichten →" + : `${e.staffProfile.workPatterns.length ? "Wochenmodell ✓" : "Wochenmodell fehlt"} · ${e.staffProfile.leaveAccounts.some((a) => a.year === year) ? `Urlaub ${year} ✓` : `Urlaub ${year} fehlt`} · ${e.staffProfile.userId ? "Account verknüpft" : "Zugang fehlt"} →`} + + + ))} +
+
+ + {pendingLeaves.length === 0 ? ( + Alle Abwesenheitsanträge sind bearbeitet. + ) : ( +
+ {pendingLeaves.map((l) => ( +
+
+
+ + {l.profile.educator.firstName}{" "} + {l.profile.educator.lastName} + +

+ {KIND_LABELS[l.kind]} ·{" "} + {shortDate(dateKey(l.startDate))} –{" "} + {shortDate(dateKey(l.endDate))} ·{" "} + {l.status === "CANCEL_REQUESTED" + ? "Stornierung" + : l.kind === "VACATION" + ? `${l.days.reduce((s, d) => s + d.vacationUnits, 0) / 2} Tage` + : formatMinutes(l.minutes ?? 0)} +

+ {l.note && ( +

+ {l.note} +

+ )} +
+ {l.profile.userId !== actor.id && ( +
+ + +
+ )} +
+
+ ))} +
+ )} +
+ + {pendingWeeks.length === 0 ? ( + Keine eingereichten Wochen zur Prüfung. + ) : ( +
+ {pendingWeeks.map((w) => ( +
+ + {w.profile.educator.firstName}{" "} + {w.profile.educator.lastName} · Woche ab{" "} + {shortDate(dateKey(w.weekStart))} prüfen → + +
+ ))} +
+ )} +
+
+ ) : ( + <> + {!profile && tab !== "kalender" && ( + + {admin && selectedEducator ? ( + + ) : ( + + {admin + ? "Lege zuerst eine Fachkraft im Personalstamm an." + : "Bitte den Kita-Admin bitten, deinen Account mit deinem Personalprofil zu verknüpfen."} + + )} + + )} + {profile && tab !== "kalender" && ( + <> +

+ {profile.educator.firstName} {profile.educator.lastName}{" "} + + · Erfassung seit {shortDate(dateKey(profile.trackingStart))} + +

+
+ + + + s + n, 0) ?? + 0, + )} + hint={ + currentPattern + ? "Netto-Sollzeit im aktuellen Wochenmodell" + : "Wochenmodell noch nicht eingerichtet" + } + /> +
+ + )} + {profile && tab === "zeit" && ( +
+ +
+
+ + + {shortDate(start)} – {shortDate(end)} + + +
+ + {STATUS_LABELS[sheet?.status ?? "DRAFT"]} + +
+
+ + {selectedEducator && ( + + )} + + +
+
+ + + + {[ + "Tag", + "Soll", + "Ist", + "Urlaub / Krankheit", + "Differenz", + ].map((s) => ( + + ))} + + + + {days.map((d, i) => ( + + + + + + + + ))} + + + + + + + + + + +
+ {s} +
+ {DAY_NAMES[i]} + + {shortDate(d.date).slice(0, 5)} + {holidaySet.has(d.date) ? " · Feiertag" : ""} + + {d.timeOff > 0 && ( +

+ {formatMinutes(d.timeOff)} Freizeitausgleich +

+ )} +
{formatMinutes(d.expected)}{formatMinutes(d.worked)}{formatMinutes(d.credit)} + {formatMinutes(d.balance)} +
Gesamt + {formatMinutes(totals.expected)} + {formatMinutes(totals.worked)}{formatMinutes(totals.credit)} + {formatMinutes(totals.balance)} +
+
+ {sheet?.decisionNote && ( +

+ Rückmeldung: {sheet.decisionNote} +

+ )} +
+ {editable && ( + + Woche zur Prüfung einreichen + + )} + {admin && + profile.userId !== actor.id && + sheet?.status === "SUBMITTED" && ( + + )} + {admin && + profile.userId !== actor.id && + sheet && + sheet.status !== "DRAFT" && ( + + )} +
+
+ + {weekEntries.length === 0 ? ( + + Noch keine Arbeitszeiten für diese Woche erfasst. + + ) : ( +
+ {weekEntries.map((e) => ( +
+
+

+ {shortDate(dateKey(e.date))} ·{" "} + {minuteClock(e.startMinute)} –{" "} + {minuteClock(e.endMinute)} +

+

+ {e.breakMinutes} Min. Pause ·{" "} + {formatMinutes( + e.endMinute - e.startMinute - e.breakMinutes, + )}{" "} + netto{e.note ? ` · ${e.note}` : ""} +

+
+ {editable && ( +
+ + + Löschen + +
+ )} +
+ ))} +
+ )} +
+ + {profile.weeks.length === 0 ? ( + + Hier erscheinen eingereichte und genehmigte Wochen. + + ) : ( +
+ {profile.weeks.map((w) => ( + + {shortDate(dateKey(w.weekStart))} ·{" "} + {STATUS_LABELS[w.status]} + + ))} +
+ )} +
+
+ )} + {profile && tab === "urlaub" && ( +
+ +
+ + {selectedEducator && ( + + )} + + +
+ {vacation ? ( +
+ {[ + ["Anspruch + Übertrag", vacation.entitlement], + ["Genommen", vacation.taken], + ["Genehmigt / geplant", vacation.planned], + ["Offene Anträge", vacation.pending], + ].map(([label, units]) => ( +
+

+ {label} +

+

+ {Number(units) / 2} Tage +

+
+ ))} +
+ ) : ( + + Der Kita-Admin muss den Jahresanspruch hinterlegen. + + )} +

+ Offene Anträge sind noch nicht genehmigt. Sie werden bei + weiteren Anträgen berücksichtigt. Eine offene Stornierung + bleibt bis zur Freigabe gebucht. +

+
+ + {profile.leaves.length === 0 ? ( + Noch keine Abwesenheiten erfasst. + ) : ( +
+ {profile.leaves.map((l) => ( +
+
+
+

+ {KIND_LABELS[l.kind]}{" "} + {l.halfDay ? "· halber Tag" : ""} +

+

+ {shortDate(dateKey(l.startDate))} –{" "} + {shortDate(dateKey(l.endDate))} ·{" "} + {l.kind === "VACATION" + ? `${l.days.reduce((s, d) => s + d.vacationUnits, 0) / 2} Tage` + : formatMinutes( + l.days.reduce( + (s, d) => s + d.creditMinutes, + 0, + ), + )} +

+ {l.note &&

{l.note}

} + {l.decisionNote && ( +

+ Rückmeldung: {l.decisionNote} +

+ )} +
+ + {STATUS_LABELS[l.status]} + +
+
+ {l.status === "PENDING" && ( + + )} + {["PENDING", "APPROVED"].includes(l.status) && ( + + {l.status === "PENDING" + ? "Zurückziehen" + : "Stornierung beantragen"} + + )} + {admin && + profile.userId !== actor.id && + ["PENDING", "CANCEL_REQUESTED"].includes( + l.status, + ) && ( + <> + + + + )} +
+
+ ))} +
+ )} +
+ + {profile.adjustments.length === 0 ? ( + Keine manuellen Zeitkorrekturen. + ) : ( +
+ {profile.adjustments + .toSorted((a, b) => b.date.getTime() - a.date.getTime()) + .map((a) => ( +

+ {shortDate(dateKey(a.date))} ·{" "} + {formatMinutes(a.minutes)} · {a.reason} +

+ ))} +
+ )} +
+
+ )} + {tab === "kalender" && ( +
+ +
+ + {selectedEducator && ( + + )} + + +
+
+ {DAY_NAMES.map((d) => ( +
+ {d.slice(0, 2)} +
+ ))} +
+
+ {Array.from( + { length: (dateOnly(monthStart).getUTCDay() + 6) % 7 }, + (_, i) => ( +
+ ), + )} + {datesBetween(monthStart, monthEnd).map((date) => { + const bookings = calendarLeaves.filter((l) => + l.days.some((d) => dateKey(d.date) === date), + ); + const holiday = holidays.find( + (h) => dateKey(h.date) === date, + ); + return ( +
+

+ {shortDate(date).slice(0, 5)} +

+ {holiday && ( +

+ {holiday.name} +

+ )} + {bookings.map((l) => ( +
+

+ {l.profile.educator.firstName}{" "} + {l.profile.educator.lastName} +

+

+ {l.kind === "SICKNESS" && !admin + ? "Abwesend" + : KIND_LABELS[l.kind]} + {l.halfDay ? " · ½" : ""} + {l.kind === "TIME_OFF" + ? ` · ${formatMinutes(l.days.find((d) => dateKey(d.date) === date)?.creditMinutes ?? 0)}` + : ""} +

+
+ ))} +
+ ); + })} +
+ + {admin && ( + + +
+ {holidays + .filter( + (h) => + h.date.getUTCFullYear() === Number(month.slice(0, 4)), + ) + .map((h) => ( +
+ + {shortDate(dateKey(h.date))} · {h.name} + + + Entfernen + +
+ ))} +
+
+ )} +
+ )} + {profile && tab === "verwaltung" && ( +
+ +

+ Beschäftigung: {shortDate(dateKey(profile.employmentStart))} + {profile.employmentEnd + ? ` bis ${shortDate(dateKey(profile.employmentEnd))}` + : " · unbefristet"}{" "} + · Anfangssaldo {formatMinutes(profile.openingMinutes)} +

+ {profile.workPatterns.length === 0 && ( + Noch kein Wochenmodell eingerichtet. + )} +
+ {profile.workPatterns.map((p) => ( +
+

+ Gültig ab {shortDate(dateKey(p.effectiveFrom))} +

+
+ {DAY_NAMES.map((day, i) => ( + + {day.slice(0, 2)}:{" "} + {p.dailyMinutes[i] + ? formatMinutes(p.dailyMinutes[i]) + : "frei"} + + ))} +
+
+ ))} +
+ {profile.dayOverrides.length > 0 && ( +
+

Tagesausnahmen

+ {profile.dayOverrides + .toSorted((a, b) => a.date.getTime() - b.date.getTime()) + .map((o) => ( +
+ + {shortDate(dateKey(o.date))} ·{" "} + {formatMinutes(o.minutes)} · {o.reason} + + {admin && ( + + Aufheben + + )} +
+ ))} +
+ )} +
+ {admin && ( + + )} + + {profile.history.length === 0 ? ( + Noch keine Änderungen. + ) : ( +
+ {profile.history.map((h) => ( +
+

+ {h.action}{" "} + + {new Intl.DateTimeFormat("de-DE", { + timeZone: "Europe/Berlin", + dateStyle: "short", + timeStyle: "short", + }).format(h.createdAt)} + +

+

+ {h.details} +

+
+ ))} +
+ )} +
+
+ )} + + )} +
+ ); +} diff --git a/src/app/dashboard/profil/page.tsx b/src/app/dashboard/profil/page.tsx index c424c33..f4c1d20 100644 --- a/src/app/dashboard/profil/page.tsx +++ b/src/app/dashboard/profil/page.tsx @@ -1,5 +1,7 @@ import { format } from "date-fns"; import { de } from "date-fns/locale"; +import Link from "next/link"; +import { UserRole } from "@prisma/client"; import { AtSign, Baby, @@ -96,7 +98,9 @@ export default async function ProfilPage() {

- Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen. + {user.role === UserRole.ERZIEHER + ? "Deine persönlichen Daten, dein Arbeitsbereich und Datenschutzeinstellungen." + : "Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen."}

@@ -124,25 +128,31 @@ export default async function ProfilPage() { label="Telefon" value={user.phone ? user.phone : } /> - } /> -

- Dein Account ist noch keinem Haushalt zugeordnet. -

- -
- ) - } + icon={Shield} + label="Rolle" + value={} /> + {user.role !== UserRole.ERZIEHER && ( + +

+ Dein Account ist noch keinem Haushalt zugeordnet. +

+ +
+ ) + } + /> + )}
+ {user.role === UserRole.ERZIEHER && ( + + + Meine Arbeitszeit & Urlaub + + Wochenmodell, Urlaubsanspruch, Zeiterfassung und Überstunden. + + + + + Personalbereich öffnen → + + + + )}
@@ -180,87 +208,91 @@ export default async function ProfilPage() { - - -
-
- -
-
- Meine Kinder - - Kinder deines Haushalts verwalten. - -
-
-
- - ({ - id: child.id, - firstName: child.firstName, - lastName: child.lastName, - dateOfBirth: child.dateOfBirth - ? child.dateOfBirth.toISOString().slice(0, 10) - : null, - }))} - canManage={!!user.familyId} - /> - -
- - - -
-
- -
-
- Meine Ämter & Dienste - - Feste Aufgaben und laufende Zuständigkeiten. - -
-
-
- - {user.dutyAssignments.length === 0 ? ( -
-
- -
-

- Keine festen Ämter übernommen -

-

- Dienste und Rollen erscheinen hier, sobald sie zugewiesen - sind. -

-
- ) : ( -
- {user.dutyAssignments.map((assignment) => ( -
-
-

- {assignment.duty.name} -

-

- laufender Dienst -

-
- - Aktiv - + {user.role !== UserRole.ERZIEHER && ( + <> + + +
+
+
- ))} -
- )} - -
+
+ Meine Kinder + + Kinder deines Haushalts verwalten. + +
+
+ + + ({ + id: child.id, + firstName: child.firstName, + lastName: child.lastName, + dateOfBirth: child.dateOfBirth + ? child.dateOfBirth.toISOString().slice(0, 10) + : null, + }))} + canManage={!!user.familyId} + /> + + + + + +
+
+ +
+
+ Meine Ämter & Dienste + + Feste Aufgaben und laufende Zuständigkeiten. + +
+
+
+ + {user.dutyAssignments.length === 0 ? ( +
+
+ +
+

+ Keine festen Ämter übernommen +

+

+ Dienste und Rollen erscheinen hier, sobald sie + zugewiesen sind. +

+
+ ) : ( +
+ {user.dutyAssignments.map((assignment) => ( +
+
+

+ {assignment.duty.name} +

+

+ laufender Dienst +

+
+ + Aktiv + +
+ ))} +
+ )} +
+
+ + )}
diff --git a/src/components/dashboard/sidebar-nav.tsx b/src/components/dashboard/sidebar-nav.tsx index 57e7186..b398dee 100644 --- a/src/components/dashboard/sidebar-nav.tsx +++ b/src/components/dashboard/sidebar-nav.tsx @@ -12,6 +12,7 @@ import { ClipboardList, Stethoscope, Megaphone, + Clock3, } from "lucide-react"; import { NavItem } from "@/components/ui/nav-item"; @@ -19,6 +20,14 @@ import { NavItem } from "@/components/ui/nav-item"; import { UserRole } from "@prisma/client"; const navItems = [ + { + section: "Heute", + href: "/dashboard/personal", + label: "Personal & Arbeitszeit", + icon: Clock3, + exact: false, + allowedRoles: [UserRole.ADMIN, UserRole.ERZIEHER], + }, { section: "Heute", href: "/dashboard", @@ -33,6 +42,7 @@ const navItems = [ icon: CalendarCheck2, exact: true, badge: "4", + allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN], }, { section: "Heute", diff --git a/src/lib/staff/auth.ts b/src/lib/staff/auth.ts new file mode 100644 index 0000000..53f062f --- /dev/null +++ b/src/lib/staff/auth.ts @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import { UserRole } from "@prisma/client"; +import { requireKitaSession } from "@/lib/auth-utils"; +import { prisma } from "@/lib/prisma"; +import type { StaffActor } from "./service"; + +export async function requireStaffActor(): Promise { + const { user } = await requireKitaSession(); + const current = await prisma.user.findFirst({ + where: { id: user.id, kitaId: user.kitaId }, + select: { + id: true, + kitaId: true, + role: true, + lockedUntil: true, + privacyPolicyAcceptedAt: true, + }, + }); + if ( + !current?.kitaId || + !current.privacyPolicyAcceptedAt || + current.role !== user.role || + (current.role !== UserRole.ADMIN && current.role !== UserRole.ERZIEHER) || + (current.lockedUntil && current.lockedUntil > new Date()) + ) + redirect("/forbidden"); + return { id: current.id, kitaId: current.kitaId, role: current.role }; +} diff --git a/src/lib/staff/calculations.ts b/src/lib/staff/calculations.ts new file mode 100644 index 0000000..3bc024f --- /dev/null +++ b/src/lib/staff/calculations.ts @@ -0,0 +1,315 @@ +import { z } from "zod"; + +export const DAY_NAMES = [ + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + "Sonntag", +]; +export const STATUS_LABELS: Record = { + PENDING: "Beantragt", + APPROVED: "Genehmigt", + REJECTED: "Abgelehnt", + CANCEL_REQUESTED: "Stornierung beantragt", + CANCELLED: "Storniert", + DRAFT: "Offen", + SUBMITTED: "Zur Prüfung", +}; +export const KIND_LABELS: Record = { + VACATION: "Urlaub", + TIME_OFF: "Freizeitausgleich", + SICKNESS: "Krankheit", +}; +export const dateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((key) => { + const d = new Date(`${key}T00:00:00Z`); + return ( + !Number.isNaN(d.getTime()) && + d.toISOString().slice(0, 10) === key && + key >= "2000-01-01" && + key <= "2100-12-31" + ); + }, "Bitte ein gültiges Datum zwischen 2000 und 2100 angeben."); + +export function dateKey(date: Date) { + return date.toISOString().slice(0, 10); +} +export function dateOnly(key: string) { + return new Date(`${dateSchema.parse(key)}T00:00:00Z`); +} +export function todayKey(now = new Date()) { + return new Intl.DateTimeFormat("sv-SE", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +} +export function addDays(key: string, amount: number) { + const d = dateOnly(key); + d.setUTCDate(d.getUTCDate() + amount); + return dateKey(d); +} +export function weekStart(key: string) { + const day = dateOnly(key).getUTCDay(); + return addDays(key, -(day === 0 ? 6 : day - 1)); +} +export function datesBetween(start: string, end: string) { + if (end < start) + throw new Error("Das Enddatum darf nicht vor dem Beginn liegen."); + if ((dateOnly(end).getTime() - dateOnly(start).getTime()) / 86400000 > 370) + throw new Error("Bitte höchstens ein Jahr auf einmal auswählen."); + const days: string[] = []; + for (let d = start; d <= end; d = addDays(d, 1)) days.push(d); + return days; +} +export function formatMinutes(minutes: number) { + const abs = Math.abs(Math.round(minutes)); + return `${minutes < 0 ? "−" : ""}${Math.floor(abs / 60)}:${String(abs % 60).padStart(2, "0")} h`; +} +export function shortDate(key: string) { + return new Intl.DateTimeFormat("de-DE", { + timeZone: "UTC", + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(dateOnly(key)); +} +export function clockMinute(value: string) { + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) + throw new Error("Bitte eine gültige Uhrzeit angeben."); + const [h, m] = value.split(":").map(Number); + return h * 60 + m; +} +export function minuteClock(value: number) { + return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`; +} + +export type Schedule = { + employmentStart: Date; + employmentEnd: Date | null; + trackingStart: Date; + workPatterns: { effectiveFrom: Date; dailyMinutes: number[] }[]; + dayOverrides: { date: Date; minutes: number }[]; +}; +export function scheduledMinutes( + profile: Schedule, + key: string, + holidays: Set, +) { + if ( + key < dateKey(profile.employmentStart) || + (profile.employmentEnd && key > dateKey(profile.employmentEnd)) || + holidays.has(key) + ) + return 0; + const override = profile.dayOverrides.find((o) => dateKey(o.date) === key); + if (override) return override.minutes; + const pattern = profile.workPatterns + .filter((p) => dateKey(p.effectiveFrom) <= key) + .sort((a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime())[0]; + const weekday = dateOnly(key).getUTCDay(); + return pattern?.dailyMinutes[weekday === 0 ? 6 : weekday - 1] ?? 0; +} + +export type BookingDay = { + date: string; + vacationUnits: number; + creditMinutes: number; +}; +export function calculateLeaveDays( + profile: Schedule, + holidays: Set, + input: { + kind: string; + startDate: string; + endDate: string; + halfDay: boolean; + minutes?: number; + }, +) { + if ( + input.startDate < dateKey(profile.employmentStart) || + (profile.employmentEnd && input.endDate > dateKey(profile.employmentEnd)) + ) + throw new Error("Der Zeitraum liegt außerhalb der Beschäftigung."); + if (input.halfDay && input.startDate !== input.endDate) + throw new Error("Halbe Tage bitte einzeln beantragen."); + if (input.kind === "TIME_OFF" && input.startDate !== input.endDate) + throw new Error( + "Freizeitausgleich bitte für einen einzelnen Tag beantragen.", + ); + const days: BookingDay[] = []; + for (const date of datesBetween(input.startDate, input.endDate)) { + const expected = scheduledMinutes(profile, date, holidays); + if (!expected) continue; + const creditMinutes = + input.kind === "TIME_OFF" + ? (input.minutes ?? 0) + : input.halfDay + ? Math.round(expected / 2) + : expected; + if (creditMinutes <= 0 || creditMinutes > expected) + throw new Error( + "Freizeitausgleich muss zwischen einer Minute und der Sollzeit dieses Tages liegen.", + ); + days.push({ + date, + creditMinutes, + vacationUnits: input.kind === "VACATION" ? (input.halfDay ? 1 : 2) : 0, + }); + } + if (!days.length) + throw new Error("Im ausgewählten Zeitraum liegen keine Arbeitstage."); + return days; +} + +export type DailyTotal = { + date: string; + expected: number; + worked: number; + credit: number; + timeOff: number; + balance: number; +}; +export function calculateWeek( + profile: Schedule, + holidays: Set, + start: string, + entries: { + date: Date; + startMinute: number; + endMinute: number; + breakMinutes: number; + }[], + leaves: { + kind: string; + status: string; + days: { date: Date; creditMinutes: number }[]; + }[], +): DailyTotal[] { + return datesBetween(start, addDays(start, 6)).map((date) => { + const expected = + date < dateKey(profile.trackingStart) + ? 0 + : scheduledMinutes(profile, date, holidays); + const worked = entries + .filter((e) => dateKey(e.date) === date) + .reduce( + (sum, e) => sum + e.endMinute - e.startMinute - e.breakMinutes, + 0, + ); + let credit = 0, + timeOff = 0; + for (const leave of leaves) { + if (!["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) continue; + const minutes = leave.days + .filter((d) => dateKey(d.date) === date) + .reduce((sum, d) => sum + d.creditMinutes, 0); + if (leave.kind === "TIME_OFF") timeOff += minutes; + else credit += minutes; + } + return { + date, + expected, + worked, + credit, + timeOff, + balance: worked + credit - expected, + }; + }); +} + +export function readSnapshot(value: unknown): DailyTotal[] { + const parsed = z + .array( + z.object({ + date: dateSchema, + expected: z.number().int(), + worked: z.number().int(), + credit: z.number().int(), + timeOff: z.number().int(), + balance: z.number().int(), + }), + ) + .safeParse(value); + return parsed.success ? parsed.data : []; +} + +export function getTimeBalance( + profile: { openingMinutes: number; trackingStart: Date }, + weeks: { status: string; dailySnapshot: unknown }[], + adjustments: { date: Date; minutes: number }[], + leaves: { + kind: string; + status: string; + days: { date: Date; creditMinutes: number }[]; + }[], + asOf = todayKey(), +) { + const approvedDays = weeks + .filter((w) => w.status === "APPROVED") + .flatMap((w) => readSnapshot(w.dailySnapshot)); + const postedDates = new Set( + approvedDays.filter((d) => d.date <= asOf).map((d) => d.date), + ); + const confirmed = + profile.openingMinutes + + approvedDays + .filter((d) => d.date <= asOf) + .reduce((sum, d) => sum + d.balance, 0) + + adjustments + .filter((a) => dateKey(a.date) <= asOf) + .reduce((sum, a) => sum + a.minutes, 0); + const reserved = leaves + .filter( + (l) => + l.kind === "TIME_OFF" && + ["APPROVED", "CANCEL_REQUESTED"].includes(l.status), + ) + .flatMap((l) => l.days) + .filter( + (d) => + dateKey(d.date) >= dateKey(profile.trackingStart) && + !postedDates.has(dateKey(d.date)), + ) + .reduce((sum, d) => sum + d.creditMinutes, 0); + return { confirmed, reserved, available: confirmed - reserved }; +} + +export function vacationBalance( + account: { year: number; entitlementUnits: number; carryUnits: number }, + leaves: { + kind: string; + status: string; + days: { date: Date; vacationUnits: number }[]; + }[], + asOf = todayKey(), +) { + let taken = 0, + planned = 0, + pending = 0; + for (const leave of leaves.filter((l) => l.kind === "VACATION")) { + for (const day of leave.days.filter( + (d) => d.date.getUTCFullYear() === account.year, + )) { + if (leave.status === "PENDING") pending += day.vacationUnits; + if (["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) { + if (dateKey(day.date) < asOf) taken += day.vacationUnits; + else planned += day.vacationUnits; + } + } + } + return { + entitlement: account.entitlementUnits + account.carryUnits, + taken, + planned, + pending, + available: account.entitlementUnits + account.carryUnits - taken - planned, + }; +} diff --git a/src/lib/staff/service.ts b/src/lib/staff/service.ts new file mode 100644 index 0000000..6673644 --- /dev/null +++ b/src/lib/staff/service.ts @@ -0,0 +1,1162 @@ +import { randomUUID } from "node:crypto"; +import { + Prisma, + UserRole, + StaffLeaveKind, + StaffRequestStatus, +} from "@prisma/client"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { + addDays, + calculateLeaveDays, + calculateWeek, + dateKey, + dateOnly, + dateSchema, + getTimeBalance, + scheduledMinutes, + todayKey, + vacationBalance, + weekStart, +} from "./calculations"; + +export type StaffActor = { id: string; kitaId: string; role: UserRole }; +export type StaffNotice = { + profileId: string; + text: string; + toAdmins: boolean; +}; +type CommandResult = { + notice?: StaffNotice; + invite?: { email: string; name: string; token: string; kitaName: string }; +}; +const idSchema = z.string().min(1).max(100); +const reasonSchema = z + .string() + .trim() + .min(3, "Bitte eine Begründung angeben.") + .max(500); +const commandSchema = z.object({ + command: z.string(), + data: z.record(z.string(), z.unknown()), +}); +const activeLeaveStatuses: StaffRequestStatus[] = [ + "PENDING", + "APPROVED", + "CANCEL_REQUESTED", +]; +const profileInclude = { + educator: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + emailVerifiedAt: true, + }, + }, + workPatterns: { orderBy: { effectiveFrom: "asc" as const } }, + dayOverrides: true, + leaveAccounts: true, + leaves: { include: { days: true }, orderBy: { createdAt: "desc" as const } }, + timeEntries: { + orderBy: [{ date: "asc" as const }, { startMinute: "asc" as const }], + }, + weeks: { orderBy: { weekStart: "desc" as const } }, + adjustments: true, + history: { orderBy: { createdAt: "desc" as const }, take: 30 }, +} satisfies Prisma.StaffProfileInclude; +export type StaffProfileData = Prisma.StaffProfileGetPayload<{ + include: typeof profileInclude; +}>; + +function requireAdmin(actor: StaffActor) { + if (actor.role !== UserRole.ADMIN) + throw new Error("Nur Kita-Admins dürfen Personal verwalten."); +} +export function assertStaffAccess( + actor: StaffActor, + profile: { kitaId: string; userId: string | null }, + adminOnly = false, +) { + if ( + profile.kitaId !== actor.kitaId || + ![UserRole.ADMIN, UserRole.ERZIEHER].includes( + actor.role as "ADMIN" | "ERZIEHER", + ) + ) + throw new Error("Kein Zugriff auf dieses Personalprofil."); + if (adminOnly) requireAdmin(actor); + else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id) + throw new Error("Du kannst nur dein eigenes Personalprofil verwalten."); +} + +export async function getStaffProfile(actor: StaffActor, profileId?: string) { + if ( + ![UserRole.ADMIN, UserRole.ERZIEHER].includes( + actor.role as "ADMIN" | "ERZIEHER", + ) + ) + throw new Error("Kein Zugriff auf den Personalbereich."); + const profile = await prisma.staffProfile.findFirst({ + where: { + kitaId: actor.kitaId, + ...(actor.role === UserRole.ADMIN && profileId + ? { id: profileId } + : { userId: actor.id }), + }, + include: profileInclude, + }); + if (profile) assertStaffAccess(actor, profile); + return profile; +} + +function ensureDayEditable(profile: StaffProfileData, key: string) { + const sheet = profile.weeks.find( + (w) => dateKey(w.weekStart) === weekStart(key), + ); + if (sheet && sheet.status !== "DRAFT") + throw new Error( + "Diese Woche ist eingereicht oder genehmigt. Der Admin muss sie zuerst zur Korrektur zurückgeben.", + ); +} +function ensureRangeEditable( + profile: StaffProfileData, + start: string, + end: string, +) { + if ( + profile.weeks.some( + (w) => + w.status !== "DRAFT" && + dateKey(w.weekStart) <= end && + addDays(dateKey(w.weekStart), 6) >= start, + ) + ) + throw new Error( + "Im Zeitraum liegt eine eingereichte oder genehmigte Woche. Bitte zuerst zur Korrektur zurückgeben.", + ); +} +function ensureModelEditable(profile: StaffProfileData, from: string) { + if ( + profile.weeks.some( + (w) => w.status !== "DRAFT" && addDays(dateKey(w.weekStart), 6) >= from, + ) || + profile.leaves.some( + (l) => + ["APPROVED", "CANCEL_REQUESTED"].includes(l.status) && + dateKey(l.endDate) >= from, + ) + ) { + throw new Error( + "Ab diesem Datum bestehen bereits Freigaben. Bitte betroffene Wochen zurückgeben bzw. Abwesenheiten stornieren oder einen späteren Beginn wählen.", + ); + } +} +function assertVacationFunds( + profile: StaffProfileData, + days: { date: string; vacationUnits: number }[], + excludeId?: string, +) { + for (const year of new Set( + days.filter((d) => d.vacationUnits).map((d) => Number(d.date.slice(0, 4))), + )) { + const account = profile.leaveAccounts.find((a) => a.year === year); + if (!account) + throw new Error( + `Für ${year} muss der Admin zuerst ein Urlaubskonto hinterlegen.`, + ); + const balance = vacationBalance( + account, + profile.leaves.filter((l) => l.id !== excludeId), + ); + const requested = days + .filter((d) => Number(d.date.slice(0, 4)) === year) + .reduce((sum, d) => sum + d.vacationUnits, 0); + if (balance.available - balance.pending < requested) + throw new Error( + `Für ${year} reichen die noch nicht verplanten Urlaubstage nicht aus.`, + ); + } +} + +/** Only called after authentication. Every command runs in a serializable transaction. */ +export async function executeStaffCommand( + actor: StaffActor, + raw: unknown, +): Promise { + const { command, data } = commandSchema.parse(raw); + if ( + ![UserRole.ADMIN, UserRole.ERZIEHER].includes( + actor.role as "ADMIN" | "ERZIEHER", + ) + ) + throw new Error("Kein Zugriff auf den Personalbereich."); + for (let attempt = 0; ; attempt++) { + try { + return await prisma.$transaction( + async (tx) => { + // Also validate the actor against the DB; a forged tenant or stale role cannot be used. + const dbActor = await tx.user.findFirst({ + where: { + id: actor.id, + kitaId: actor.kitaId, + role: actor.role, + privacyPolicyAcceptedAt: { not: null }, + }, + }); + if ( + !dbActor || + (dbActor.lockedUntil && dbActor.lockedUntil > new Date()) + ) + throw new Error( + "Dein Konto hat keinen Zugriff auf den Personalbereich.", + ); + if (command === "holiday") { + requireAdmin(actor); + const input = z + .object({ + date: dateSchema, + name: z.string().trim().min(2).max(100), + remove: z.boolean().optional(), + }) + .parse(data); + const date = dateOnly(input.date); + const protectedWeek = await tx.staffWeek.count({ + where: { + profile: { kitaId: actor.kitaId }, + status: { not: "DRAFT" }, + weekStart: dateOnly(weekStart(input.date)), + }, + }); + const protectedLeave = await tx.staffLeave.count({ + where: { + profile: { kitaId: actor.kitaId }, + status: { in: ["APPROVED", "CANCEL_REQUESTED"] }, + startDate: { lte: date }, + endDate: { gte: date }, + }, + }); + if (protectedWeek || protectedLeave) + throw new Error( + "An diesem Tag bestehen bereits Freigaben. Bitte zuerst zurückgeben oder stornieren.", + ); + if (input.remove) + await tx.staffHoliday.deleteMany({ + where: { kitaId: actor.kitaId, date }, + }); + else + await tx.staffHoliday.upsert({ + where: { kitaId_date: { kitaId: actor.kitaId, date } }, + create: { kitaId: actor.kitaId, date, name: input.name }, + update: { name: input.name }, + }); + await tx.auditLog.create({ + data: { + actorUserId: actor.id, + actorEmail: dbActor.email, + action: "STAFF_HOLIDAY_CHANGE", + targetType: "KITA", + targetId: actor.kitaId, + targetLabel: `${input.date}: ${input.name}`, + targetKitaId: actor.kitaId, + }, + }); + return {}; + } + if (command === "setup") { + requireAdmin(actor); + const input = z + .object({ + educatorId: idSchema, + employmentStart: dateSchema, + employmentEnd: dateSchema.nullable(), + trackingStart: dateSchema, + openingMinutes: z.number().int().min(-200000).max(200000), + reason: reasonSchema, + }) + .parse(data); + if ( + input.employmentEnd && + input.employmentEnd < input.employmentStart + ) + throw new Error("Das Beschäftigungsende liegt vor dem Beginn."); + if ( + input.trackingStart < input.employmentStart || + (input.employmentEnd && input.trackingStart > input.employmentEnd) + ) + throw new Error( + "Der Erfassungsbeginn muss in der Beschäftigung liegen.", + ); + const educator = await tx.educator.findFirst({ + where: { id: input.educatorId, kitaId: actor.kitaId }, + }); + if (!educator) throw new Error("Fachkraft nicht gefunden."); + const existing = await tx.staffProfile.findFirst({ + where: { educatorId: educator.id, kitaId: actor.kitaId }, + include: profileInclude, + }); + if ( + existing && + (existing.timeEntries.length || + existing.leaves.length || + existing.weeks.length) + ) + throw new Error( + "Die Grunddaten sind nach Beginn der Erfassung geschützt. Verwende datierte Arbeitszeitmodelle und begründete Zeitkorrekturen.", + ); + const values = { + employmentStart: dateOnly(input.employmentStart), + employmentEnd: input.employmentEnd + ? dateOnly(input.employmentEnd) + : null, + trackingStart: dateOnly(input.trackingStart), + openingMinutes: input.openingMinutes, + }; + const profile = await tx.staffProfile.upsert({ + where: { educatorId: educator.id }, + create: { + ...values, + educatorId: educator.id, + kitaId: actor.kitaId, + }, + update: values, + }); + await tx.staffHistory.create({ + data: { + profileId: profile.id, + actorId: actor.id, + action: "Beschäftigungsprofil", + details: `${input.reason} · Beginn ${input.employmentStart}, Erfassung ${input.trackingStart}, Anfangssaldo ${input.openingMinutes} Min.`, + }, + }); + return {}; + } + const profileId = idSchema.parse(data.profileId); + const profile = await tx.staffProfile.findFirst({ + where: { id: profileId, kitaId: actor.kitaId }, + include: profileInclude, + }); + if (!profile) throw new Error("Personalprofil nicht gefunden."); + assertStaffAccess(actor, profile); + const holidays = new Set( + ( + await tx.staffHoliday.findMany({ + where: { kitaId: actor.kitaId }, + }) + ).map((h) => dateKey(h.date)), + ); + const history = async (action: string, details: string) => { + await tx.staffHistory.create({ + data: { profileId, actorId: actor.id, action, details }, + }); + }; + const notice = async ( + text: string, + toAdmins: boolean, + ): Promise => { + const recipients = await tx.user.findMany({ + where: { + kitaId: actor.kitaId, + ...(toAdmins + ? { role: UserRole.ADMIN } + : { id: profile.userId ?? "" }), + }, + select: { id: true }, + }); + await tx.staffNotification.createMany({ + data: recipients.map((user) => ({ + userId: user.id, + profileId, + message: `${profile.educator.firstName} ${profile.educator.lastName}: ${text}`, + })), + }); + return { notice: { profileId, text, toAdmins } }; + }; + if (command === "noticeRead") { + await tx.staffNotification.updateMany({ + where: { + id: idSchema.parse(data.id), + userId: actor.id, + profileId, + }, + data: { readAt: new Date() }, + }); + return {}; + } + + if (command === "invite") { + requireAdmin(actor); + const email = z + .string() + .trim() + .email() + .toLowerCase() + .parse(data.email); + if (profile.userId) { + if (profile.user?.email !== email || profile.user.emailVerifiedAt) + throw new Error( + "Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.", + ); + } + const existingUser = await tx.user.findUnique({ where: { email } }); + if ( + existingUser && + (existingUser.kitaId !== actor.kitaId || + existingUser.role !== UserRole.ERZIEHER || + (await tx.staffProfile.count({ + where: { userId: existingUser.id, id: { not: profileId } }, + }))) + ) + throw new Error( + "Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.", + ); + const user = + existingUser ?? + (await tx.user.create({ + data: { + email, + firstName: profile.educator.firstName, + lastName: profile.educator.lastName, + passwordHash: "", + role: UserRole.ERZIEHER, + kitaId: actor.kitaId, + }, + })); + await tx.staffProfile.update({ + where: { id: profileId }, + data: { userId: user.id }, + }); + await history("Account verknüpft", email); + if (user.emailVerifiedAt) return {}; + const token = randomUUID(); + await tx.verificationToken.deleteMany({ + where: { identifier: user.id }, + }); + await tx.verificationToken.create({ + data: { + identifier: user.id, + token, + expires: new Date(Date.now() + 7 * 86400000), + }, + }); + const kita = await tx.kita.findUniqueOrThrow({ + where: { id: actor.kitaId }, + select: { name: true }, + }); + return { + invite: { + email, + name: `${profile.educator.firstName} ${profile.educator.lastName}`, + token, + kitaName: kita.name, + }, + }; + } + if (command === "pattern") { + requireAdmin(actor); + const input = z + .object({ + effectiveFrom: dateSchema, + dailyMinutes: z + .array(z.number().int().min(0).max(960)) + .length(7), + reason: reasonSchema, + }) + .parse(data); + if (input.effectiveFrom < dateKey(profile.employmentStart)) + throw new Error( + "Das Modell darf nicht vor Beschäftigungsbeginn gelten.", + ); + ensureModelEditable(profile, input.effectiveFrom); + await tx.staffWorkPattern.upsert({ + where: { + profileId_effectiveFrom: { + profileId, + effectiveFrom: dateOnly(input.effectiveFrom), + }, + }, + create: { + profileId, + effectiveFrom: dateOnly(input.effectiveFrom), + dailyMinutes: input.dailyMinutes, + }, + update: { dailyMinutes: input.dailyMinutes }, + }); + await history( + "Wochenmodell", + `${input.effectiveFrom}: ${input.dailyMinutes.join(" / ")} Min. · ${input.reason}`, + ); + return {}; + } + if (command === "employmentEnd") { + requireAdmin(actor); + const input = z + .object({ + employmentEnd: dateSchema.nullable(), + reason: reasonSchema, + }) + .parse(data); + if ( + input.employmentEnd && + input.employmentEnd < dateKey(profile.employmentStart) + ) + throw new Error("Das Beschäftigungsende liegt vor dem Beginn."); + if ( + input.employmentEnd && + (input.employmentEnd < dateKey(profile.trackingStart) || + profile.timeEntries.some( + (e) => dateKey(e.date) > input.employmentEnd!, + ) || + profile.leaves.some( + (l) => + activeLeaveStatuses.includes(l.status) && + dateKey(l.endDate) > input.employmentEnd!, + )) + ) + throw new Error( + "Nach diesem Ende bestehen Erfassungsbeginn, Arbeitszeiten oder aktive Anträge. Bitte zuerst korrigieren.", + ); + const oldEnd = profile.employmentEnd + ? dateKey(profile.employmentEnd) + : null; + if (oldEnd !== input.employmentEnd) { + const changedFrom = [oldEnd, input.employmentEnd] + .filter((d): d is string => d !== null) + .sort()[0]; + if (changedFrom) + ensureModelEditable(profile, addDays(changedFrom, 1)); + } + await tx.staffProfile.update({ + where: { id: profileId }, + data: { + employmentEnd: input.employmentEnd + ? dateOnly(input.employmentEnd) + : null, + }, + }); + await history( + "Beschäftigungsende", + `${input.employmentEnd ?? "unbefristet"} · ${input.reason}`, + ); + return {}; + } + if (command === "override") { + requireAdmin(actor); + const input = z + .object({ + date: dateSchema, + minutes: z.number().int().min(0).max(960), + reason: reasonSchema, + remove: z.boolean().optional(), + }) + .parse(data); + ensureDayEditable(profile, input.date); + if ( + profile.leaves.some( + (l) => + ["APPROVED", "CANCEL_REQUESTED"].includes(l.status) && + dateKey(l.startDate) <= input.date && + dateKey(l.endDate) >= input.date, + ) + ) + throw new Error( + "Bitte die genehmigte Abwesenheit zuerst stornieren.", + ); + if ( + input.date < dateKey(profile.employmentStart) || + (profile.employmentEnd && + input.date > dateKey(profile.employmentEnd)) + ) + throw new Error("Der Tag liegt außerhalb der Beschäftigung."); + if (input.remove) + await tx.staffDayOverride.deleteMany({ + where: { profileId, date: dateOnly(input.date) }, + }); + else + await tx.staffDayOverride.upsert({ + where: { + profileId_date: { profileId, date: dateOnly(input.date) }, + }, + create: { + profileId, + date: dateOnly(input.date), + minutes: input.minutes, + reason: input.reason, + }, + update: { minutes: input.minutes, reason: input.reason }, + }); + await history( + "Tagesausnahme", + `${input.date}: ${input.remove ? "entfernt" : `${input.minutes} Min.`} · ${input.reason}`, + ); + return {}; + } + if (command === "account") { + requireAdmin(actor); + const input = z + .object({ + year: z.number().int().min(2000).max(2100), + entitlementUnits: z.number().int().min(0).max(800), + carryUnits: z.number().int().min(0).max(800), + reason: reasonSchema, + }) + .parse(data); + const used = vacationBalance({ ...input }, profile.leaves); + if (used.available < 0 || used.available < used.pending) + throw new Error( + "Der Anspruch reicht nicht für bereits genehmigten oder beantragten Urlaub.", + ); + await tx.staffLeaveAccount.upsert({ + where: { profileId_year: { profileId, year: input.year } }, + create: { + profileId, + year: input.year, + entitlementUnits: input.entitlementUnits, + carryUnits: input.carryUnits, + }, + update: { + entitlementUnits: input.entitlementUnits, + carryUnits: input.carryUnits, + }, + }); + await history( + "Urlaubsanspruch", + `${input.year}: ${input.entitlementUnits / 2} Tage + ${input.carryUnits / 2} Übertrag · ${input.reason}`, + ); + return {}; + } + if (command === "adjust") { + requireAdmin(actor); + const input = z + .object({ + date: dateSchema, + minutes: z + .number() + .int() + .min(-200000) + .max(200000) + .refine((n) => n !== 0), + reason: reasonSchema, + }) + .parse(data); + if (input.date < dateKey(profile.trackingStart)) + throw new Error("Korrekturen müssen ab Erfassungsbeginn liegen."); + await tx.staffAdjustment.create({ + data: { + profileId, + date: dateOnly(input.date), + minutes: input.minutes, + reason: input.reason, + actorId: actor.id, + }, + }); + await history( + "Zeitkorrektur", + `${input.date}: ${input.minutes} Min. · ${input.reason}`, + ); + return {}; + } + if (command === "time" || command === "deleteTime") { + if (!profile.educator.active) + throw new Error("Die Fachkraft ist nicht mehr aktiv."); + const existing = data.id + ? await tx.staffTimeEntry.findFirst({ + where: { id: idSchema.parse(data.id), profileId }, + }) + : null; + if (data.id && !existing) + throw new Error("Zeiteintrag nicht gefunden."); + if (existing) ensureDayEditable(profile, dateKey(existing.date)); + if (command === "deleteTime") { + if (!existing) throw new Error("Zeiteintrag nicht gefunden."); + await tx.staffTimeEntry.delete({ where: { id: existing.id } }); + await history( + "Arbeitszeit gelöscht", + `${dateKey(existing.date)}: ${existing.startMinute}–${existing.endMinute} Min.`, + ); + return {}; + } + const input = z + .object({ + date: dateSchema, + startMinute: z.number().int().min(0).max(1439), + endMinute: z.number().int().min(1).max(1440), + breakMinutes: z.number().int().min(0).max(1439), + note: z.string().trim().max(500).optional(), + }) + .parse(data); + if ( + input.date > todayKey() || + input.date < dateKey(profile.trackingStart) || + input.date < dateKey(profile.employmentStart) || + (profile.employmentEnd && + input.date > dateKey(profile.employmentEnd)) + ) + throw new Error( + "Bitte einen vergangenen oder heutigen Tag innerhalb des Erfassungszeitraums wählen.", + ); + if ( + !profile.workPatterns.some( + (p) => dateKey(p.effectiveFrom) <= input.date, + ) + ) + throw new Error( + "Bitte zuerst ein Arbeitszeitmodell hinterlegen.", + ); + if ( + input.endMinute <= input.startMinute || + input.breakMinutes >= input.endMinute - input.startMinute + ) + throw new Error( + "Ende muss nach Beginn liegen; die Pause muss kürzer als der Arbeitsblock sein.", + ); + ensureDayEditable(profile, input.date); + if ( + profile.timeEntries.some( + (e) => + e.id !== existing?.id && + dateKey(e.date) === input.date && + e.startMinute < input.endMinute && + e.endMinute > input.startMinute, + ) + ) + throw new Error( + "Dieser Arbeitsblock überschneidet sich mit einem vorhandenen Eintrag.", + ); + const absences = profile.leaves + .filter((l) => + ["APPROVED", "CANCEL_REQUESTED"].includes(l.status), + ) + .flatMap((l) => l.days) + .filter((d) => dateKey(d.date) === input.date); + const reservedMinutes = absences.reduce( + (sum, d) => sum + d.creditMinutes, + 0, + ); + const worked = profile.timeEntries + .filter( + (e) => e.id !== existing?.id && dateKey(e.date) === input.date, + ) + .reduce( + (sum, e) => sum + e.endMinute - e.startMinute - e.breakMinutes, + input.endMinute - input.startMinute - input.breakMinutes, + ); + if ( + reservedMinutes && + worked + reservedMinutes > + scheduledMinutes(profile, input.date, holidays) + ) + throw new Error( + "Arbeitszeit und genehmigte Abwesenheit überschreiten die Sollzeit. Bitte die Abwesenheit zuerst korrigieren.", + ); + const values = { + profileId, + date: dateOnly(input.date), + startMinute: input.startMinute, + endMinute: input.endMinute, + breakMinutes: input.breakMinutes, + note: input.note || null, + }; + if (existing) + await tx.staffTimeEntry.update({ + where: { id: existing.id }, + data: values, + }); + else await tx.staffTimeEntry.create({ data: values }); + await history( + "Arbeitszeit gespeichert", + `${input.date}: ${input.startMinute}–${input.endMinute}, Pause ${input.breakMinutes} Min.`, + ); + return {}; + } + if (command === "weekSubmit" || command === "weekReview") { + const start = weekStart(dateSchema.parse(data.weekStart)); + const end = addDays(start, 6); + if (end < dateKey(profile.trackingStart) || start > todayKey()) + throw new Error( + "Diese Woche liegt außerhalb der bisherigen Erfassung.", + ); + const existing = profile.weeks.find( + (w) => dateKey(w.weekStart) === start, + ); + if (command === "weekSubmit") { + if (existing && existing.status !== "DRAFT") + throw new Error( + "Die Woche ist bereits eingereicht oder genehmigt.", + ); + if ( + !profile.workPatterns.some( + (p) => + dateKey(p.effectiveFrom) <= + (start < dateKey(profile.trackingStart) + ? dateKey(profile.trackingStart) + : start), + ) + ) + throw new Error( + "Bitte zuerst ein Arbeitszeitmodell für die Woche hinterlegen.", + ); + if ( + profile.leaves.some( + (l) => + ["PENDING", "CANCEL_REQUESTED"].includes(l.status) && + dateKey(l.startDate) <= end && + dateKey(l.endDate) >= start, + ) + ) + throw new Error( + "Bitte zuerst offene Abwesenheitsanträge dieser Woche klären.", + ); + const days = calculateWeek( + profile, + holidays, + start, + profile.timeEntries, + profile.leaves, + ); + if (days.some((d) => d.date > todayKey() && d.expected > 0)) + throw new Error( + "Die Woche kann erst nach ihrem letzten vorgesehenen Arbeitstag eingereicht werden.", + ); + await tx.staffWeek.upsert({ + where: { + profileId_weekStart: { + profileId, + weekStart: dateOnly(start), + }, + }, + create: { + profileId, + weekStart: dateOnly(start), + status: "SUBMITTED", + submittedAt: new Date(), + }, + update: { + status: "SUBMITTED", + submittedAt: new Date(), + dailySnapshot: Prisma.DbNull, + decisionNote: null, + }, + }); + await history("Woche eingereicht", start); + return notice( + `Arbeitswoche ab ${start} wurde zur Prüfung eingereicht.`, + true, + ); + } + requireAdmin(actor); + if (profile.userId === actor.id) + throw new Error( + "Eigene Wochen dürfen nicht selbst freigegeben werden.", + ); + const input = z + .object({ approve: z.boolean(), reason: reasonSchema }) + .parse(data); + if ( + !existing || + (input.approve + ? existing.status !== "SUBMITTED" + : existing.status === "DRAFT") + ) + throw new Error( + "Diese Woche kann in ihrem aktuellen Status nicht bearbeitet werden.", + ); + if ( + input.approve && + profile.leaves.some( + (l) => + ["PENDING", "CANCEL_REQUESTED"].includes(l.status) && + dateKey(l.startDate) <= end && + dateKey(l.endDate) >= start, + ) + ) + throw new Error( + "Bitte offene Abwesenheiten zuerst klären; gegebenenfalls die Woche zur Korrektur zurückgeben.", + ); + const days = calculateWeek( + profile, + holidays, + start, + profile.timeEntries, + profile.leaves, + ); + const snapshot = days.map((d) => ({ ...d })); + await tx.staffWeek.update({ + where: { id: existing.id }, + data: { + status: input.approve ? "APPROVED" : "DRAFT", + dailySnapshot: input.approve ? snapshot : Prisma.DbNull, + reviewedAt: new Date(), + reviewedById: actor.id, + decisionNote: input.reason, + }, + }); + await history( + input.approve ? "Woche genehmigt" : "Woche zur Korrektur", + `${start} · ${input.reason}`, + ); + return notice( + `Arbeitswoche ab ${start}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}. ${input.reason}`, + false, + ); + } + if ( + command === "leave" || + command === "leaveReview" || + command === "leaveCancel" + ) { + const existing = data.id + ? profile.leaves.find((l) => l.id === idSchema.parse(data.id)) + : undefined; + if (data.id && !existing) throw new Error("Antrag nicht gefunden."); + if (command === "leaveCancel") { + if ( + !existing || + !["PENDING", "APPROVED"].includes(existing.status) + ) + throw new Error( + "Dieser Antrag kann nicht zurückgezogen werden.", + ); + const status = + existing.status === "PENDING" + ? "CANCELLED" + : "CANCEL_REQUESTED"; + await tx.staffLeave.update({ + where: { id: existing.id }, + data: { status }, + }); + await history( + "Abwesenheit zurückgezogen", + `${existing.id}: ${status}`, + ); + return notice( + status === "CANCELLED" + ? "Ein offener Abwesenheitsantrag wurde zurückgezogen." + : "Die Stornierung einer genehmigten Abwesenheit wurde beantragt.", + true, + ); + } + if (command === "leaveReview") { + requireAdmin(actor); + if (profile.userId === actor.id) + throw new Error( + "Eigene Anträge dürfen nicht selbst genehmigt werden.", + ); + const input = z + .object({ approve: z.boolean(), reason: reasonSchema }) + .parse(data); + if ( + !existing || + !["PENDING", "CANCEL_REQUESTED"].includes(existing.status) + ) + throw new Error("Dieser Antrag ist nicht mehr offen."); + const cancelling = existing.status === "CANCEL_REQUESTED"; + if (input.approve) + ensureRangeEditable( + profile, + dateKey(existing.startDate), + dateKey(existing.endDate), + ); + if (input.approve && !cancelling) { + const days = calculateLeaveDays(profile, holidays, { + kind: existing.kind, + startDate: dateKey(existing.startDate), + endDate: dateKey(existing.endDate), + halfDay: existing.halfDay, + minutes: existing.minutes ?? undefined, + }); + if (existing.kind === "VACATION") + assertVacationFunds(profile, days, existing.id); + if (existing.kind === "TIME_OFF") { + const balance = getTimeBalance( + profile, + profile.weeks, + profile.adjustments, + profile.leaves, + ); + if (balance.available < (existing.minutes ?? 0)) + throw new Error( + "Das bestätigte Zeitguthaben reicht nicht aus.", + ); + } + for (const d of days) { + const worked = profile.timeEntries + .filter((e) => dateKey(e.date) === d.date) + .reduce( + (sum, e) => + sum + e.endMinute - e.startMinute - e.breakMinutes, + 0, + ); + if ( + worked + d.creditMinutes > + scheduledMinutes(profile, d.date, holidays) + ) + throw new Error( + "An diesem Tag bestehen Arbeitszeiten. Bitte Zeitraum oder Arbeitszeit zuerst korrigieren.", + ); + } + await tx.staffLeaveDay.deleteMany({ + where: { leaveId: existing.id }, + }); + await tx.staffLeaveDay.createMany({ + data: days.map((d) => ({ + ...d, + date: dateOnly(d.date), + leaveId: existing.id, + })), + }); + } + const status: StaffRequestStatus = cancelling + ? input.approve + ? "CANCELLED" + : "APPROVED" + : input.approve + ? "APPROVED" + : "REJECTED"; + await tx.staffLeave.update({ + where: { id: existing.id }, + data: { + status, + reviewedById: actor.id, + reviewedAt: new Date(), + decisionNote: input.reason, + }, + }); + await history( + "Abwesenheit entschieden", + `${existing.id}: ${status} · ${input.reason}`, + ); + return notice( + `${existing.kind === "VACATION" ? "Urlaub" : existing.kind === "TIME_OFF" ? "Freizeitausgleich" : "Krankmeldung"}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : "genehmigt"}. ${input.reason}`, + false, + ); + } + if (!profile.educator.active) + throw new Error("Die Fachkraft ist nicht mehr aktiv."); + const input = z + .object({ + kind: z.nativeEnum(StaffLeaveKind), + startDate: dateSchema, + endDate: dateSchema, + halfDay: z.boolean(), + minutes: z.number().int().min(1).max(960).optional(), + note: z.string().trim().max(500).optional(), + }) + .parse(data); + if (input.kind === "SICKNESS") requireAdmin(actor); + if (existing && existing.status !== "PENDING") + throw new Error("Nur offene Anträge können bearbeitet werden."); + if (input.startDate < dateKey(profile.trackingStart)) + throw new Error( + "Abwesenheiten bitte ab Erfassungsbeginn erfassen.", + ); + ensureRangeEditable(profile, input.startDate, input.endDate); + if ( + profile.leaves.some( + (l) => + l.id !== existing?.id && + activeLeaveStatuses.includes(l.status) && + dateKey(l.startDate) <= input.endDate && + dateKey(l.endDate) >= input.startDate, + ) + ) + throw new Error( + "Der Zeitraum überschneidet sich mit einer offenen oder genehmigten Abwesenheit.", + ); + const days = calculateLeaveDays(profile, holidays, input); + if (input.kind === "VACATION") + assertVacationFunds(profile, days, existing?.id); + if (input.kind === "TIME_OFF") { + const balance = getTimeBalance( + profile, + profile.weeks, + profile.adjustments, + profile.leaves, + ); + const pending = profile.leaves + .filter( + (l) => + l.id !== existing?.id && + l.kind === "TIME_OFF" && + l.status === "PENDING", + ) + .reduce((sum, l) => sum + (l.minutes ?? 0), 0); + if (balance.available - pending < (input.minutes ?? 0)) + throw new Error( + "Das noch nicht verplante bestätigte Zeitguthaben reicht nicht aus.", + ); + } + if (input.kind === "SICKNESS") { + for (const day of days) + if ( + profile.timeEntries.some((e) => dateKey(e.date) === day.date) + ) + throw new Error( + "Für Krankheitstage mit Arbeitszeit bitte zuerst die Arbeitszeit korrigieren.", + ); + } + const values = { + kind: input.kind, + startDate: dateOnly(input.startDate), + endDate: dateOnly(input.endDate), + halfDay: input.halfDay, + minutes: input.kind === "TIME_OFF" ? input.minutes : null, + note: input.note || null, + status: + input.kind === "SICKNESS" + ? StaffRequestStatus.APPROVED + : StaffRequestStatus.PENDING, + ...(input.kind === "SICKNESS" + ? { reviewedById: actor.id, reviewedAt: new Date() } + : {}), + }; + const leave = existing + ? await tx.staffLeave.update({ + where: { id: existing.id }, + data: values, + }) + : await tx.staffLeave.create({ + data: { + ...values, + profileId, + requestedById: actor.id, + }, + }); + await tx.staffLeaveDay.deleteMany({ where: { leaveId: leave.id } }); + await tx.staffLeaveDay.createMany({ + data: days.map((d) => ({ + ...d, + date: dateOnly(d.date), + leaveId: leave.id, + })), + }); + await history( + "Abwesenheit erfasst", + `${input.kind}: ${input.startDate}–${input.endDate}`, + ); + return notice( + `${input.kind === "SICKNESS" ? "Krankheit wurde hinterlegt" : "Ein Abwesenheitsantrag wurde eingereicht"}: ${input.startDate}–${input.endDate}.`, + input.kind !== "SICKNESS", + ); + } + throw new Error("Unbekannte Aktion."); + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + timeout: 15000, + }, + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2034" && + attempt < 2 + ) + continue; + throw error; + } + } +} diff --git a/tests/staff-calculations.test.ts b/tests/staff-calculations.test.ts new file mode 100644 index 0000000..6e4d838 --- /dev/null +++ b/tests/staff-calculations.test.ts @@ -0,0 +1,237 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + calculateLeaveDays, + calculateWeek, + dateOnly, + dateSchema, + getTimeBalance, + scheduledMinutes, + todayKey, + vacationBalance, + weekStart, +} from "../src/lib/staff/calculations"; + +const profile = { + employmentStart: dateOnly("2026-01-01"), + employmentEnd: null, + trackingStart: dateOnly("2026-01-01"), + openingMinutes: 120, + workPatterns: [ + { + effectiveFrom: dateOnly("2026-01-01"), + dailyMinutes: [360, 360, 0, 360, 0, 0, 0], + }, + ], + dayOverrides: [], +}; +const holidays = new Set(["2026-01-06"]); +const days = (date: string, vacationUnits: number, creditMinutes = 360) => [ + { date: dateOnly(date), vacationUnits, creditMinutes }, +]; + +test("Teilzeiturlaub verbraucht nur feste Arbeitstage, Feiertage werden übersprungen", () => { + const result = calculateLeaveDays(profile, holidays, { + kind: "VACATION", + startDate: "2026-01-05", + endDate: "2026-01-11", + halfDay: false, + }); + assert.deepEqual( + result.map((d) => d.date), + ["2026-01-05", "2026-01-08"], + ); + assert.equal( + result.reduce((s, d) => s + d.vacationUnits, 0), + 4, + ); +}); +test("Halber Urlaubstag entspricht halbem Tages-Soll", () => { + assert.deepEqual( + calculateLeaveDays(profile, new Set(), { + kind: "VACATION", + startDate: "2026-01-05", + endDate: "2026-01-05", + halfDay: true, + }), + [{ date: "2026-01-05", vacationUnits: 1, creditMinutes: 180 }], + ); + assert.throws(() => + calculateLeaveDays(profile, holidays, { + kind: "VACATION", + startDate: "2026-01-05", + endDate: "2026-01-08", + halfDay: true, + }), + ); +}); +test("Tagesausnahmen und datierte Modelle verändern nur den vorgesehenen Zeitraum", () => { + const schedule = { + ...profile, + dayOverrides: [{ date: dateOnly("2026-01-07"), minutes: 240 }], + workPatterns: [ + ...profile.workPatterns, + { + effectiveFrom: dateOnly("2026-02-01"), + dailyMinutes: [480, 0, 480, 0, 0, 0, 0], + }, + ], + }; + assert.equal(scheduledMinutes(schedule, "2026-01-07", holidays), 240); + assert.equal(scheduledMinutes(schedule, "2026-01-05", holidays), 360); + assert.equal(scheduledMinutes(schedule, "2026-02-02", holidays), 480); + assert.equal(scheduledMinutes(schedule, "2026-01-06", holidays), 0); + assert.equal( + scheduledMinutes( + { ...schedule, employmentEnd: dateOnly("2026-01-31") }, + "2026-02-02", + holidays, + ), + 0, + ); +}); +test("Urlaub über Jahreswechsel wird auf die Jahreskonten verteilt", () => { + const leave = [ + { + kind: "VACATION", + status: "APPROVED", + days: [...days("2026-12-31", 2), ...days("2027-01-04", 1)], + }, + ]; + assert.equal( + vacationBalance( + { year: 2026, entitlementUnits: 40, carryUnits: 2 }, + leave, + "2026-10-01", + ).planned, + 2, + ); + assert.equal( + vacationBalance( + { year: 2027, entitlementUnits: 40, carryUnits: 0 }, + leave, + "2026-10-01", + ).planned, + 1, + ); +}); +test("Offene Stornierung bleibt gebucht, Ablehnung und Stornierung verbrauchen nichts", () => { + const leave = [ + { + kind: "VACATION", + status: "CANCEL_REQUESTED", + days: days("2026-01-05", 2), + }, + { kind: "VACATION", status: "PENDING", days: days("2026-01-08", 1) }, + ...["CANCELLED", "REJECTED"].map((status) => ({ + kind: "VACATION", + status, + days: days("2026-01-12", 2), + })), + ]; + assert.deepEqual( + vacationBalance( + { year: 2026, entitlementUnits: 40, carryUnits: 4 }, + leave, + "2026-01-07", + ), + { entitlement: 44, taken: 2, planned: 0, pending: 1, available: 42 }, + ); +}); +test("Mehrere Arbeitsblöcke abzüglich Pausen, Urlaub und Krankheit ergeben die Wochenbilanz", () => { + const entries = [ + { + date: dateOnly("2026-01-05"), + startMinute: 480, + endMinute: 720, + breakMinutes: 30, + }, + { + date: dateOnly("2026-01-05"), + startMinute: 780, + endMinute: 960, + breakMinutes: 0, + }, + ]; + const leave = [ + { kind: "SICKNESS", status: "APPROVED", days: days("2026-01-08", 0) }, + ]; + const result = calculateWeek(profile, holidays, "2026-01-05", entries, leave); + assert.equal(result[0].worked, 390); + assert.equal(result[0].balance, 30); + assert.equal(result[1].expected, 0); + assert.equal(result[3].credit, 360); + assert.equal(result[3].balance, 0); +}); +test("Nur genehmigte Snapshots zählen; Zukunftskorrekturen zählen erst ab Datum", () => { + const snapshot = [ + { + date: "2026-01-05", + expected: 360, + worked: 420, + credit: 0, + timeOff: 0, + balance: 60, + }, + ]; + const result = getTimeBalance( + profile, + [ + { status: "APPROVED", dailySnapshot: snapshot }, + { status: "SUBMITTED", dailySnapshot: snapshot }, + ], + [{ date: dateOnly("2026-02-01"), minutes: 100 }], + [], + "2026-01-10", + ); + assert.equal(result.confirmed, 180); +}); +test("Freizeitausgleich wird reserviert und nach Wochenbuchung genau einmal abgezogen", () => { + const leave = [ + { kind: "TIME_OFF", status: "APPROVED", days: days("2026-01-05", 0, 60) }, + ]; + const initial = getTimeBalance(profile, [], [], leave, "2026-01-04"); + assert.deepEqual(initial, { confirmed: 120, reserved: 60, available: 60 }); + const snapshot = [ + { + date: "2026-01-05", + expected: 360, + worked: 300, + credit: 0, + timeOff: 60, + balance: -60, + }, + ]; + assert.deepEqual( + getTimeBalance( + profile, + [{ status: "APPROVED", dailySnapshot: snapshot }], + [], + leave, + "2026-01-06", + ), + { confirmed: 60, reserved: 0, available: 60 }, + ); + const computed = calculateWeek( + profile, + new Set(), + "2026-01-05", + [ + { + date: dateOnly("2026-01-05"), + startMinute: 480, + endMinute: 780, + breakMinutes: 0, + }, + ], + leave, + ); + assert.equal(computed[0].credit, 0); + assert.equal(computed[0].balance, -60); +}); +test("Ungültige Kalendertage werden abgewiesen; Tagesdatum wird in Berlin bestimmt", () => { + assert.equal(dateSchema.safeParse("2026-02-30").success, false); + assert.equal(dateSchema.safeParse("2028-02-29").success, true); + assert.equal(todayKey(new Date("2026-01-04T23:30:00Z")), "2026-01-05"); + assert.equal(weekStart("2026-01-11"), "2026-01-05"); +}); diff --git a/tests/staff-service.test.ts b/tests/staff-service.test.ts new file mode 100644 index 0000000..6b4ad74 --- /dev/null +++ b/tests/staff-service.test.ts @@ -0,0 +1,518 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { UserRole } from "@prisma/client"; +import { prisma } from "../src/lib/prisma"; +import { + executeStaffCommand, + getStaffProfile, + type StaffActor, +} from "../src/lib/staff/service"; +import { dateOnly, getTimeBalance } from "../src/lib/staff/calculations"; + +// Never run integration tests against a user's application database. +const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid"); +if ( + db.hostname !== "127.0.0.1" || + db.port !== "55439" || + db.pathname !== "/kita_staff_test" +) + throw new Error( + "Integrationstests benötigen die isolierte Datenbank 127.0.0.1:55439/kita_staff_test.", + ); +const tenants: string[] = []; +after(async () => { + await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); + await prisma.$disconnect(); +}); +async function fixture() { + const kita = await prisma.kita.create({ + data: { name: "Testkita", slug: `staff-test-${randomUUID()}` }, + }); + tenants.push(kita.id); + const createUser = (role: UserRole) => + prisma.user.create({ + data: { + kitaId: kita.id, + email: `${randomUUID()}@staff-test.invalid`, + passwordHash: "test-only", + firstName: "Test", + lastName: role, + role, + privacyPolicyAcceptedAt: new Date(), + }, + }); + const adminUser = await createUser("ADMIN"), + staffUser = await createUser("ERZIEHER"), + otherUser = await createUser("ERZIEHER"), + parentUser = await createUser("ELTERN"); + const actor = (u: typeof adminUser): StaffActor => ({ + id: u.id, + kitaId: kita.id, + role: u.role, + }); + const admin = actor(adminUser), + staff = actor(staffUser), + other = actor(otherUser), + parent = actor(parentUser); + const educator = await prisma.educator.create({ + data: { kitaId: kita.id, firstName: "Test", lastName: "Fachkraft" }, + }); + await executeStaffCommand(admin, { + command: "setup", + data: { + educatorId: educator.id, + employmentStart: "2026-01-01", + employmentEnd: null, + trackingStart: "2026-01-01", + openingMinutes: 600, + reason: "Testeinrichtung", + }, + }); + const profile = await prisma.staffProfile.update({ + where: { educatorId: educator.id }, + data: { userId: staffUser.id }, + }); + const run = ( + who: StaffActor, + command: string, + data: Record = {}, + ) => + executeStaffCommand(who, { + command, + data: { profileId: profile.id, ...data }, + }); + await run(admin, "pattern", { + effectiveFrom: "2026-01-01", + dailyMinutes: [360, 360, 0, 360, 0, 0, 0], + reason: "Feste Teilzeit", + }); + await run(admin, "account", { + year: 2026, + entitlementUnits: 40, + carryUnits: 0, + reason: "Jahresanspruch", + }); + return { + kita, + profile, + educator, + admin, + staff, + other, + parent, + run, + staffUser, + }; +} +const vacation = (date = "2026-01-05") => ({ + kind: "VACATION", + startDate: date, + endDate: date, + halfDay: false, + note: "Testurlaub", +}); +const time = (date = "2026-01-05", startMinute = 480, endMinute = 840) => ({ + date, + startMinute, + endMinute, + breakMinutes: 0, +}); + +test("Mandanten, Rollen und Eigentümer werden für Lesen und Schreiben geprüft", async () => { + const f = await fixture(), + foreign = await fixture(); + await assert.rejects( + f.run(f.other, "time", time()), + /eigenes Personalprofil/, + ); + await assert.rejects(f.run(f.parent, "time", time()), /Kein Zugriff/); + await assert.rejects( + f.run(f.staff, "account", { + year: 2026, + entitlementUnits: 100, + carryUnits: 0, + reason: "Versuch", + }), + /Nur Kita-Admins/, + ); + await assert.rejects(f.run(foreign.admin, "time", time()), /nicht gefunden/); + await assert.rejects( + f.run({ ...f.admin, kitaId: foreign.kita.id }, "time", time()), + /keinen Zugriff/, + ); + assert.equal(await getStaffProfile(f.other, f.profile.id), null); + assert.equal(await getStaffProfile(foreign.admin, f.profile.id), null); + await assert.rejects(getStaffProfile(f.parent, f.profile.id), /Kein Zugriff/); +}); +test("Urlaubsantrag, Freigabe, Stornierung und interne Hinweise bleiben konsistent", async () => { + const f = await fixture(); + await f.run(f.staff, "leave", vacation()); + const leave = await prisma.staffLeave.findFirstOrThrow({ + where: { profileId: f.profile.id }, + include: { days: true }, + }); + assert.equal(leave.days[0].vacationUnits, 2); + assert.equal( + await prisma.staffNotification.count({ where: { userId: f.admin.id } }), + 1, + ); + await f.run(f.admin, "leaveReview", { + id: leave.id, + approve: true, + reason: "Freigegeben", + }); + await f.run(f.staff, "leaveCancel", { id: leave.id }); + assert.equal( + (await prisma.staffLeave.findUniqueOrThrow({ where: { id: leave.id } })) + .status, + "CANCEL_REQUESTED", + ); + await f.run(f.admin, "leaveReview", { + id: leave.id, + approve: true, + reason: "Stornierung bestätigt", + }); + assert.equal( + (await prisma.staffLeave.findUniqueOrThrow({ where: { id: leave.id } })) + .status, + "CANCELLED", + ); + assert.equal( + await prisma.staffNotification.count({ where: { userId: f.staff.id } }), + 2, + ); + const notice = await prisma.staffNotification.findFirstOrThrow({ + where: { userId: f.staff.id }, + }); + await f.run(f.admin, "noticeRead", { id: notice.id }); + assert.equal( + ( + await prisma.staffNotification.findUniqueOrThrow({ + where: { id: notice.id }, + }) + ).readAt, + null, + ); + await f.run(f.staff, "noticeRead", { id: notice.id }); + assert.ok( + ( + await prisma.staffNotification.findUniqueOrThrow({ + where: { id: notice.id }, + }) + ).readAt, + ); +}); +test("Offene Urlaube reservieren den Anspruch und lassen sich nur im offenen Status ändern", async () => { + const f = await fixture(); + await f.run(f.admin, "account", { + year: 2026, + entitlementUnits: 2, + carryUnits: 0, + reason: "Ein Tag", + }); + await f.run(f.staff, "leave", vacation()); + await assert.rejects( + f.run(f.staff, "leave", vacation("2026-01-08")), + /reichen.*nicht aus/, + ); + const leave = await prisma.staffLeave.findFirstOrThrow({ + where: { profileId: f.profile.id }, + }); + await f.run(f.staff, "leave", { ...vacation("2026-01-08"), id: leave.id }); + await f.run(f.admin, "leaveReview", { + id: leave.id, + approve: true, + reason: "Genehmigung", + }); + await assert.rejects( + f.run(f.staff, "leave", { ...vacation(), id: leave.id }), + /Nur offene/, + ); +}); +test("Überlappende Arbeitsblöcke und parallele Urlaubsanträge werden verhindert", async () => { + const f = await fixture(); + await f.run(f.staff, "time", time()); + await assert.rejects( + f.run(f.staff, "time", time("2026-01-05", 800, 900)), + /überschneidet/, + ); + await f.run(f.admin, "account", { + year: 2026, + entitlementUnits: 2, + carryUnits: 0, + reason: "Ein Tag", + }); + const results = await Promise.allSettled([ + f.run(f.staff, "leave", vacation("2026-01-08")), + f.run(f.staff, "leave", vacation("2026-01-12")), + ]); + assert.equal(results.filter((r) => r.status === "fulfilled").length, 1); + assert.equal( + await prisma.staffLeave.count({ where: { profileId: f.profile.id } }), + 1, + ); +}); +test("Wochenfreigabe schützt Zeiten, Modelle und Feiertage; Rückgabe erlaubt Korrektur", async () => { + const f = await fixture(); + for (const date of ["2026-01-05", "2026-01-06", "2026-01-08"]) + await f.run( + f.staff, + "time", + time(date, 480, date === "2026-01-05" ? 900 : 840), + ); + await f.run(f.staff, "weekSubmit", { weekStart: "2026-01-05" }); + await assert.rejects( + f.run(f.staff, "time", time("2026-01-05", 1000, 1100)), + /zuerst.*zurückgeben/, + ); + await f.run(f.admin, "weekReview", { + weekStart: "2026-01-05", + approve: true, + reason: "Zeiten geprüft", + }); + let p = await getStaffProfile(f.staff); + assert.ok(p); + assert.equal( + getTimeBalance(p, p.weeks, p.adjustments, p.leaves).confirmed, + 660, + ); + await assert.rejects( + f.run(f.admin, "pattern", { + effectiveFrom: "2026-01-01", + dailyMinutes: [480, 480, 0, 480, 0, 0, 0], + reason: "Änderung", + }), + /bestehen bereits Freigaben/, + ); + await assert.rejects( + f.run(f.admin, "holiday", { date: "2026-01-05", name: "Feiertag" }), + /bestehen bereits Freigaben/, + ); + await f.run(f.admin, "weekReview", { + weekStart: "2026-01-05", + approve: false, + reason: "Bitte Pause prüfen", + }); + p = await getStaffProfile(f.staff); + assert.ok(p); + assert.equal( + getTimeBalance(p, p.weeks, p.adjustments, p.leaves).confirmed, + 600, + ); + await f.run(f.staff, "time", time("2026-01-05", 1000, 1100)); +}); +test("Freizeitausgleich bleibt bei Storno reserviert und wird mit der Woche einmal verrechnet", async () => { + const f = await fixture(); + await f.run(f.staff, "leave", { + kind: "TIME_OFF", + startDate: "2026-01-05", + endDate: "2026-01-05", + halfDay: false, + minutes: 60, + }); + const leave = await prisma.staffLeave.findFirstOrThrow({ + where: { profileId: f.profile.id }, + }); + await f.run(f.admin, "leaveReview", { + id: leave.id, + approve: true, + reason: "Guthaben geprüft", + }); + let p = await getStaffProfile(f.staff); + assert.ok(p); + assert.equal( + getTimeBalance(p, p.weeks, p.adjustments, p.leaves).available, + 540, + ); + for (const date of ["2026-01-05", "2026-01-06", "2026-01-08"]) + await f.run( + f.staff, + "time", + time(date, 480, date === "2026-01-05" ? 780 : 840), + ); + await f.run(f.staff, "weekSubmit", { weekStart: "2026-01-05" }); + await f.run(f.staff, "leaveCancel", { id: leave.id }); + await assert.rejects( + f.run(f.admin, "weekReview", { + weekStart: "2026-01-05", + approve: true, + reason: "Geprüft", + }), + /offene Abwesenheiten/, + ); + await f.run(f.admin, "leaveReview", { + id: leave.id, + approve: false, + reason: "Stornierung abgelehnt", + }); + await f.run(f.admin, "weekReview", { + weekStart: "2026-01-05", + approve: true, + reason: "Geprüft", + }); + p = await getStaffProfile(f.staff); + assert.ok(p); + assert.deepEqual(getTimeBalance(p, p.weeks, p.adjustments, p.leaves), { + confirmed: 540, + reserved: 0, + available: 540, + }); +}); +test("Halbtagsurlaub lässt nur die verbleibende Sollzeit als Arbeitszeit zu", async () => { + const f = await fixture(); + await f.run(f.staff, "leave", { ...vacation(), halfDay: true }); + const leave = await prisma.staffLeave.findFirstOrThrow({ + where: { profileId: f.profile.id }, + }); + await f.run(f.admin, "leaveReview", { + id: leave.id, + approve: true, + reason: "Halber Tag", + }); + await f.run(f.staff, "time", time("2026-01-05", 480, 660)); + await assert.rejects( + f.run(f.staff, "time", time("2026-01-05", 700, 760)), + /überschreiten.*Sollzeit/, + ); +}); +test("Aktivierungslinks verknüpfen nur passende Accounts und werden bei Erneuerung ersetzt", async () => { + const f = await fixture(); + const educator = await prisma.educator.create({ + data: { kitaId: f.kita.id, firstName: "Neue", lastName: "Fachkraft" }, + }); + await f.run(f.admin, "setup", { + educatorId: educator.id, + employmentStart: "2026-01-01", + employmentEnd: null, + trackingStart: "2026-01-01", + openingMinutes: 0, + reason: "Einrichtung", + }); + const profile = await prisma.staffProfile.findUniqueOrThrow({ + where: { educatorId: educator.id }, + }); + const email = `${randomUUID()}@staff-test.invalid`; + const first = await f.run(f.admin, "invite", { + profileId: profile.id, + email, + }); + assert.ok(first.invite); + const second = await f.run(f.admin, "invite", { + profileId: profile.id, + email, + }); + assert.ok(second.invite); + assert.equal( + await prisma.verificationToken.count({ + where: { token: first.invite.token }, + }), + 0, + ); + const linked = await prisma.staffProfile.findUniqueOrThrow({ + where: { id: profile.id }, + include: { user: true }, + }); + assert.equal(linked.user?.role, "ERZIEHER"); + assert.equal(linked.user?.passwordHash, ""); + await assert.rejects( + f.run(f.admin, "invite", { + profileId: profile.id, + email: f.staffUser.email, + }), + /bereits einen Account/, + ); + await prisma.verificationToken.deleteMany({ + where: { identifier: linked.userId! }, + }); +}); +test("Krankheit ist adminverwaltet, entlastet Sollzeit und verbraucht keinen Urlaub", async () => { + const f = await fixture(); + const input = { + kind: "SICKNESS", + startDate: "2026-01-08", + endDate: "2026-01-08", + halfDay: false, + }; + await assert.rejects(f.run(f.staff, "leave", input), /Nur Kita-Admins/); + await f.run(f.admin, "leave", input); + const leave = await prisma.staffLeave.findFirstOrThrow({ + where: { profileId: f.profile.id }, + include: { days: true }, + }); + assert.equal(leave.status, "APPROVED"); + assert.equal(leave.days[0].creditMinutes, 360); + assert.equal(leave.days[0].vacationUnits, 0); + assert.equal(leave.days[0].date.getTime(), dateOnly("2026-01-08").getTime()); +}); + +test("Beschäftigungsende bleibt nach Erfassungsstart verwaltbar und schützt vorhandene Zeiten", async () => { + const f = await fixture(); + await f.run(f.staff, "time", time()); + await f.run(f.admin, "employmentEnd", { + employmentEnd: "2026-01-31", + reason: "Vertragsende", + }); + await assert.rejects( + f.run(f.staff, "time", time("2026-02-02")), + /innerhalb des Erfassungszeitraums/, + ); + await assert.rejects( + f.run(f.admin, "employmentEnd", { + employmentEnd: "2026-01-04", + reason: "Zu früh", + }), + /aktive Anträge/, + ); + await f.run(f.admin, "employmentEnd", { + employmentEnd: null, + reason: "Vertrag verlängert", + }); + await f.run(f.staff, "time", time("2026-02-02")); +}); + +test("Personalaufzeichnungen sind vor Fachkraftlöschung geschützt und bleiben nach Accountlöschung erhalten", async () => { + const f = await fixture(); + await f.run(f.staff, "time", time()); + await assert.rejects( + prisma.educator.delete({ where: { id: f.educator.id } }), + (error: unknown) => + typeof error === "object" && + error !== null && + "code" in error && + error.code === "P2003", + ); + await prisma.user.delete({ where: { id: f.staff.id } }); + const profile = await prisma.staffProfile.findUniqueOrThrow({ + where: { id: f.profile.id }, + include: { timeEntries: true }, + }); + assert.equal(profile.userId, null); + assert.equal(profile.timeEntries.length, 1); +}); + +test("Entzogene Rollen, fehlender Consent und gesperrte Accounts können keine Personalaktionen ausführen", async () => { + const f = await fixture(); + await assert.rejects( + f.run({ ...f.staff, role: "ADMIN" }, "account", { + year: 2026, + entitlementUnits: 100, + carryUnits: 0, + reason: "Manipulation", + }), + /keinen Zugriff/, + ); + await prisma.user.update({ + where: { id: f.staff.id }, + data: { privacyPolicyAcceptedAt: null }, + }); + await assert.rejects(f.run(f.staff, "time", time()), /keinen Zugriff/); + await prisma.user.update({ + where: { id: f.staff.id }, + data: { + privacyPolicyAcceptedAt: new Date(), + lockedUntil: new Date(Date.now() + 60000), + }, + }); + await assert.rejects(f.run(f.staff, "time", time()), /keinen Zugriff/); +});