Merge current features with security hardening and automatic safe initialization
This commit is contained in:
+1
-1
@@ -46,4 +46,4 @@ COPY --from=builder --chown=app:app /app/public ./public
|
||||
RUN mkdir -p /app/uploads/announcements/announcements /app/.next/cache && chown -R app:app /app/uploads /app/.next/cache
|
||||
USER app
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["sh", "scripts/start-production.sh"]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Deployment und Mailversand
|
||||
|
||||
Die aktuelle Anleitung steht in [README.md](README.md), die Konfigurationsvorlage in [production.env.example](production.env.example).
|
||||
|
||||
Der Compose-Dienst `migrate` initialisiert das Schema über versionierte Migrationen und stellt den separaten Runtime-Zugang bereit. Die App startet erst nach erfolgreicher Initialisierung. `prisma db push`, Demo-Seeding und historische SQL-Upgrades sind im Produktionsstart nicht vorgesehen.
|
||||
|
||||
Für Resend müssen Versanddomain, `RESEND_API_KEY` und `EMAIL_FROM` gültig konfiguriert sein. `ADMIN_EMAIL` ist das Ziel für das Kontaktformular. Die drei öffentlichen Basis-URLs müssen übereinstimmen, damit Einladungs- und Bestätigungslinks auf die richtige Anwendung zeigen. Der Notdienst-Worker startet im App-Container mit `NOTDIENST_JOB_SECRET`; kein zusätzlicher Cron-Dienst ist erforderlich.
|
||||
+13
-11
@@ -4,12 +4,18 @@ Die Anwendung startet ohne Schemaänderungen und ohne Demo-Seeding. Datenbankmig
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Werte aus `production.env.example` als Hosting-Secrets hinterlegen. `APP_DATABASE_URL` enthält die eingeschränkte Rolle; `MIGRATION_DATABASE_URL` den Datenbankeigentümer. Für lokale CLI-Befehle heißt die jeweils benötigte URL `DATABASE_URL`. `APP_DATABASE_PASSWORD` ist das separate Runtime-Passwort (24–256 Zeichen); in URLs muss es URL-kodiert werden. Keine echten Secrets im Repository speichern.
|
||||
Werte aus `production.env.example` als Hosting-Secrets hinterlegen. Im Compose-Stack erzeugt der Dienst `migrate` automatisch ein separates Runtime-Passwort im privaten Secret-Volume, wendet die Migrationen an, vergibt die eingeschränkten DB-Rechte und bereitet die Upload-/Cache-Volumes vor. Danach startet `app` mit UID/GID 1001. Das Eigentümerpasswort steht nur PostgreSQL und dem Initialisierungsdienst zur Verfügung. Für manuelle CLI-Befehle außerhalb von Compose bleibt `DATABASE_URL` die jeweilige administrative oder eingeschränkte URL. Keine echten Secrets im Repository speichern.
|
||||
|
||||
`AUTH_URL`, `NEXTAUTH_URL` und `NEXT_PUBLIC_SITE_URL` müssen dieselbe kanonische HTTPS-Origin verwenden. Der Reverse Proxy terminiert TLS, routet intern auf App-Port 3000 und überschreibt weitergeleitete Host-/IP-Header. `TRUSTED_CLIENT_IP_HEADER` nur setzen, wenn der Proxy den gewählten Header (`x-real-ip` oder `cf-connecting-ip`) zuverlässig überschreibt. Ohne diese Einstellung gelten weiterhin gemeinsame globale und identitätsbezogene Datenbanklimits. Proxyseitig Body-Limit von höchstens 26 MiB, Timeouts und zusätzliche Request-Limits setzen.
|
||||
|
||||
Compose startet PostgreSQL standardmäßig intern. Bei einer bereits extern verwalteten Coolify-Datenbank den Stack entsprechend anpassen und deren interne URL verwenden; niemals versehentlich einen neuen leeren Datenbankdienst als Ersatz anschließen. pgAdmin ist nur im Profil `maintenance` und nur auf Loopback verfügbar; Zugriff über SSH-Tunnel.
|
||||
|
||||
## Neustart mit leerer Datenbank
|
||||
|
||||
Wenn vorhandene Daten ausdrücklich löschbare Testdaten sind: ausschließlich die zu dieser Installation gehörenden Datenbank- und Upload-Volumes zurücksetzen. Danach genügt ein normales Deployment mit `docker compose -f docker-compose.coolify.yml up -d --build app`. Es sind weder eine Baseline-Markierung noch manuelle Schema-Upgrades oder Demo-Seeding erforderlich. Der Initialisierungsdienst läuft vor der App; ein Fehler verhindert deren Start. Bestehende produktive Daten erfordern dagegen die unten beschriebene Vorbereitung.
|
||||
|
||||
Im Normalbetrieb bleiben Datenbank, Uploads, Cache und Runtime-Secret-Volume bei Deployments bestehen. Das Runtime-Passwort wird bei einem normalen Neustart wiederverwendet. Niemals die Datenbank als Startnebenwirkung löschen.
|
||||
|
||||
## Bestehende Installation aktualisieren
|
||||
|
||||
1. Datenbank **und** Upload-Volume sichern und Wiederherstellung überprüfen. Wartungsfenster ankündigen und alte App stoppen: das neue Schema und der neue Code gehören zusammen.
|
||||
@@ -24,24 +30,20 @@ Compose-Beispiele (Environment-Secrets vorher bereitstellen):
|
||||
```sh
|
||||
docker compose -f docker-compose.coolify.yml build app migrate
|
||||
# NUR bei bestehender, zuvor geprüfter Baseline-Datenbank:
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npx prisma migrate resolve --applied 20261009000000_baseline
|
||||
# Bei neuen und bestehenden Datenbanken:
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run db:migrate:deploy
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run db:provision-runtime
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run uploads:backfill
|
||||
# Nur auf dem vorgesehenen App-Upload-Volume:
|
||||
docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate chown -R 1001:1001 /app/uploads
|
||||
# Falls ein bereits vorhandenes Cache-Volume root gehört: dessen Eigentümer separat korrigieren.
|
||||
docker compose -f docker-compose.coolify.yml run --rm migrate npx prisma migrate resolve --applied 20261009000000_baseline
|
||||
# Initialisierung läuft automatisch vor der App:
|
||||
docker compose -f docker-compose.coolify.yml up -d app
|
||||
```
|
||||
|
||||
Die Baseline-Markierung benötigt bei einem manuellen Aufruf eine administrative `DATABASE_URL`; für eine leere Datenbank entfällt sie vollständig. Vorhandene Uploads müssen vor dem Start kontrolliert und ihre Größen unter administrativem Zugang nachgetragen werden. Für bestehende root-eigene Unterverzeichnisse ist eine gezielte Eigentümerkorrektur nötig; die Initialisierung bereitet die Basisverzeichnisse vor.
|
||||
|
||||
Bei einem Rollback nach Migration zuerst Kompatibilität prüfen; das alte Image allein zurückzusetzen reicht wegen neuer Pflichtfelder nicht aus. Im Zweifel das geprüfte Backup von Datenbank und Dateien gemeinsam wiederherstellen.
|
||||
|
||||
## Auswirkungen auf bestehende Konten
|
||||
|
||||
Alle JWT-Sitzungen vor diesem Release werden ungültig; erneute Anmeldung ist erforderlich. Administrative Sperren entwerten auch bei späterer Entsperrung dauerhaft die alten Sitzungen. Gründerkonten ohne Kita und ohne echte E-Mail-Prüfung registrieren sich mit derselben Adresse erneut und bestätigen den Link. Bereits bestehende Kita-Gründer behalten ihre bisherige Verifizierung; deren historische Adressen werden dadurch nicht nachträglich als echte Mailbox-Bestätigung nachgewiesen.
|
||||
|
||||
Neue Registrierungen bestätigen zuerst die E-Mail-Adresse, wählen anschließend ihr Passwort und erstellen erst danach eine Kita. Beim Ändern einer Elternadresse werden Passwort und bisherige Sitzungen entwertet; die neue Adresse erhält eine Einladung zum Festlegen eines neuen Passworts. Kita-Admins können die Einladung erneut senden. Neue Registrierungslinks gelten 30 Minuten, Einladungen sieben Tage und Notdienst-Bestätigungslinks höchstens 24 Stunden sowie nur für offene, noch relevante Einsätze. Bestehende UUID-Links bleiben innerhalb ihrer Laufzeit kompatibel. Zwei-Faktor-Anmeldung ist auf ausdrücklichen Wunsch vorerst nicht eingeführt.
|
||||
Neue Registrierungen bestätigen zuerst die E-Mail-Adresse, wählen anschließend ihr Passwort und erstellen erst danach eine Kita. Beim Ändern einer Elternadresse werden Passwort und bisherige Sitzungen entwertet; die neue Adresse erhält eine Einladung zum Festlegen eines neuen Passworts. Kita-Admins können die Einladung erneut senden. Neue Registrierungslinks gelten 30 Minuten, Einladungen sieben Tage und Notdienst-Rückmeldelinks bis zum Ende des jeweiligen Einsatzes; eine gemeinsame Haushaltsantwort kann nur einmal gespeichert werden. Sperren entwerten vorhandene Rückmeldelinks dauerhaft. Die öffentlichen Token-Antworten zeigen nur die benötigten Einsatzangaben. Bestehende UUID-Links bleiben innerhalb ihrer Laufzeit kompatibel. Zwei-Faktor-Anmeldung ist auf ausdrücklichen Wunsch vorerst nicht eingeführt.
|
||||
|
||||
## Dateien und Wartung
|
||||
|
||||
@@ -51,7 +53,7 @@ Grenzen: 10 MiB pro Datei, fünf Dateien und 25 MiB pro Nachricht; standardmäß
|
||||
|
||||
`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt.
|
||||
|
||||
Die SQL-Migrationen ergänzen **31 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
|
||||
Die SQL-Migrationen ergänzen **41 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen.
|
||||
|
||||
## Verifikation und Updates
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Production-Checkliste
|
||||
|
||||
## Vor dem Rollout
|
||||
## Vor dem Rollout mit bestehenden Daten
|
||||
|
||||
Bei einem ausdrücklich genehmigten Reset löschbarer Testdaten entfallen Backup-Übernahme, Baseline-Markierung und Upload-Backfill. Nach dem gezielten Reset initialisiert das normale Deployment den leeren Stand.
|
||||
|
||||
- [ ] Datenbank und Uploads gesichert; Wiederherstellung geprüft.
|
||||
- [ ] Alte App gestoppt; Wartungsfenster und erneute Anmeldung angekündigt.
|
||||
- [ ] Bestehendes Schema mit Baseline verglichen; Tenant-Preflight ohne Befunde.
|
||||
- [ ] Baseline nur bei bestehender passender DB markiert; anschließend alle Migrationen angewendet.
|
||||
- [ ] Runtime-Rolle mit separatem Passwort bereitgestellt; App verwendet `APP_DATABASE_URL`.
|
||||
- [ ] Initialisierungsdienst erfolgreich; App verwendet automatisch die eingeschränkte Runtime-Rolle.
|
||||
- [ ] Upload-Größen nachgetragen; gemeldete fehlende Dateien geprüft.
|
||||
- [ ] Upload- und Cache-Volumes für UID/GID 1001 beschreibbar; Upload-Pfad unverändert.
|
||||
|
||||
|
||||
@@ -11,10 +11,9 @@ POSTGRES_DB="kita_planer"
|
||||
POSTGRES_USER="kita_planer"
|
||||
POSTGRES_PASSWORD="[GENERATE_ME]"
|
||||
|
||||
# Unterschiedliche DB-Rollen für Runtime und Migration. URL-Passwörter müssen URL-kodiert sein.
|
||||
APP_DATABASE_PASSWORD="[GENERATE_ME]"
|
||||
APP_DATABASE_URL="postgresql://kita_runtime:[URL_ENCODED_APP_PASSWORD]@postgres:5432/kita_planer?schema=public"
|
||||
MIGRATION_DATABASE_URL="postgresql://kita_planer:[URL_ENCODED_OWNER_PASSWORD]@postgres:5432/kita_planer?schema=public"
|
||||
# Der Initialisierungsdienst erzeugt automatisch einen separaten Runtime-Zugang.
|
||||
# Keine zusätzlichen APP_DATABASE_URL-/Passwort-Secrets nötig.
|
||||
# Passwort wird nur im privaten kita_planer_runtime_secrets-Volume gespeichert.
|
||||
|
||||
# pgAdmin im Coolify-Compose-Stack
|
||||
PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]"
|
||||
@@ -41,6 +40,10 @@ RESEND_API_KEY="re_[PRODUCTION_API_KEY]"
|
||||
EMAIL_FROM="Kita-Planer <[absender@deine-domain.de]>"
|
||||
ADMIN_EMAIL="[kontakt@deine-domain.de]"
|
||||
|
||||
# Automatische Notdienst-Fristpruefung im App-Container.
|
||||
# Eigenes Secret, mindestens 32 Zeichen; generieren mit: openssl rand -hex 32
|
||||
NOTDIENST_JOB_SECRET="[GENERATE_ME]"
|
||||
|
||||
# Persistenter Upload-Speicher fuer Anhaenge vom Schwarzen Brett.
|
||||
# Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen.
|
||||
UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
-- Additives Upgrade für einen Personalbereich einschließlich vorhandener Mail-Erweiterung.
|
||||
-- Einmalig vor dem Start der neuen Anwendung ausführen, sofern nicht prisma db push verwendet wird.
|
||||
BEGIN;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstAlarmStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'LATE_CONFIRMATION', 'REVIEW_REQUIRED', 'CANCELLED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "notdienst_alerts" ADD COLUMN "alarmId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarms" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"assignmentId" TEXT,
|
||||
"activeAssignmentId" TEXT,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"familyName" TEXT NOT NULL,
|
||||
"childName" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"wholeDay" BOOLEAN NOT NULL DEFAULT true,
|
||||
"startMinute" INTEGER,
|
||||
"endMinute" INTEGER,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"deadline" TIMESTAMP(3) NOT NULL,
|
||||
"status" "NotdienstAlarmStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"triggeredById" TEXT,
|
||||
"triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"respondedById" TEXT,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"responseSource" TEXT,
|
||||
"escalatedAt" TIMESTAMP(3),
|
||||
"educatorId" TEXT,
|
||||
"notes" TEXT,
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"cancelReason" TEXT,
|
||||
|
||||
CONSTRAINT "notdienst_alarms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_recipients" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_recipients_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventKey" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"readAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "notdienst_alarm_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventKey" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarms_activeAssignmentId_key" ON "notdienst_alarms"("activeAssignmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarms_kitaId_date_idx" ON "notdienst_alarms"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarms_status_deadline_idx" ON "notdienst_alarms"("status", "deadline");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_recipients_token_key" ON "notdienst_alarm_recipients"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_recipients_alarmId_userId_key" ON "notdienst_alarm_recipients"("alarmId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_notifications_userId_readAt_idx" ON "notdienst_alarm_notifications"("userId", "readAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_notifications_alarmId_userId_eventKey_key" ON "notdienst_alarm_notifications"("alarmId", "userId", "eventKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_emails_sentAt_skippedAt_createdAt_idx" ON "notdienst_alarm_emails"("sentAt", "skippedAt", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_emails_alarmId_userId_eventKey_key" ON "notdienst_alarm_emails"("alarmId", "userId", "eventKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_history_alarmId_createdAt_idx" ON "notdienst_alarm_history"("alarmId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_history" ADD CONSTRAINT "notdienst_alarm_history_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Nur E-Mail-Erweiterung für einen bereits mit 043fed2 eingeführten Personalbereich.
|
||||
-- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
|
||||
BEGIN;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"inviteToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,286 @@
|
||||
-- Einmalige Einführung des vollständigen Personalbereichs inklusive E-Mails ab ec3f4f9.
|
||||
-- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
|
||||
BEGIN;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCEL_REQUESTED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffLeaveKind" AS ENUM ('VACATION', 'TIME_OFF', 'SICKNESS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffWeekStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'APPROVED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"educatorId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"employmentStart" DATE NOT NULL,
|
||||
"employmentEnd" DATE,
|
||||
"trackingStart" DATE NOT NULL,
|
||||
"openingMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"inviteToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_work_patterns" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"effectiveFrom" DATE NOT NULL,
|
||||
"dailyMinutes" INTEGER[],
|
||||
|
||||
CONSTRAINT "staff_work_patterns_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_day_overrides" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_day_overrides_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_holidays" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_holidays_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leave_accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"entitlementUnits" INTEGER NOT NULL,
|
||||
"carryUnits" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "staff_leave_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leaves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"kind" "StaffLeaveKind" NOT NULL,
|
||||
"status" "StaffRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE NOT NULL,
|
||||
"halfDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"minutes" INTEGER,
|
||||
"note" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"requestedById" TEXT NOT NULL,
|
||||
"reviewedById" TEXT,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_leaves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leave_days" (
|
||||
"id" TEXT NOT NULL,
|
||||
"leaveId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"vacationUnits" INTEGER NOT NULL DEFAULT 0,
|
||||
"creditMinutes" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_leave_days_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_time_entries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"startMinute" INTEGER NOT NULL,
|
||||
"endMinute" INTEGER NOT NULL,
|
||||
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"note" TEXT,
|
||||
|
||||
CONSTRAINT "staff_time_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_weeks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"weekStart" DATE NOT NULL,
|
||||
"status" "StaffWeekStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"submittedAt" TIMESTAMP(3),
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"reviewedById" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"dailySnapshot" JSONB,
|
||||
|
||||
CONSTRAINT "staff_weeks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_adjustments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_adjustments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_profiles_educatorId_key" ON "staff_profiles"("educatorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_profiles_userId_key" ON "staff_profiles"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_profiles_kitaId_idx" ON "staff_profiles"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_work_patterns_profileId_effectiveFrom_key" ON "staff_work_patterns"("profileId", "effectiveFrom");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_day_overrides_profileId_date_key" ON "staff_day_overrides"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_holidays_kitaId_date_key" ON "staff_holidays"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_leave_accounts_profileId_year_key" ON "staff_leave_accounts"("profileId", "year");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_leaves_profileId_status_startDate_idx" ON "staff_leaves"("profileId", "status", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_leave_days_leaveId_date_key" ON "staff_leave_days"("leaveId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_time_entries_profileId_date_idx" ON "staff_time_entries"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_weeks_profileId_weekStart_key" ON "staff_weeks"("profileId", "weekStart");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_adjustments_profileId_date_idx" ON "staff_adjustments"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_history_profileId_createdAt_idx" ON "staff_history"("profileId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_notifications_userId_readAt_createdAt_idx" ON "staff_notifications"("userId", "readAt", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_work_patterns" ADD CONSTRAINT "staff_work_patterns_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_day_overrides" ADD CONSTRAINT "staff_day_overrides_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_holidays" ADD CONSTRAINT "staff_holidays_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leave_accounts" ADD CONSTRAINT "staff_leave_accounts_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leaves" ADD CONSTRAINT "staff_leaves_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leave_days" ADD CONSTRAINT "staff_leave_days_leaveId_fkey" FOREIGN KEY ("leaveId") REFERENCES "staff_leaves"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_time_entries" ADD CONSTRAINT "staff_time_entries_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_weeks" ADD CONSTRAINT "staff_weeks_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_adjustments" ADD CONSTRAINT "staff_adjustments_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_history" ADD CONSTRAINT "staff_history_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMIT;
|
||||
@@ -27,7 +27,8 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
DATABASE_URL: ${APP_DATABASE_URL:?}
|
||||
APP_DATABASE_PASSWORD_FILE: /app/runtime-secrets/database-password
|
||||
APP_DATABASE_NAME: ${POSTGRES_DB:-kita_planer}
|
||||
AUTH_SECRET: ${AUTH_SECRET:?}
|
||||
AUTH_URL: ${AUTH_URL:?}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?}
|
||||
@@ -38,6 +39,7 @@ services:
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:?}
|
||||
EMAIL_FROM: ${EMAIL_FROM:?}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?}
|
||||
NOTDIENST_JOB_SECRET: ${NOTDIENST_JOB_SECRET:?}
|
||||
UPLOAD_DIR: /app/uploads/announcements
|
||||
UPLOAD_TENANT_QUOTA_BYTES: ${UPLOAD_TENANT_QUOTA_BYTES:-268435456}
|
||||
UPLOAD_GLOBAL_QUOTA_BYTES: ${UPLOAD_GLOBAL_QUOTA_BYTES:-10737418240}
|
||||
@@ -45,9 +47,12 @@ services:
|
||||
volumes:
|
||||
- kita_planer_uploads:/app/uploads
|
||||
- kita_planer_next_cache:/app/.next/cache
|
||||
- kita_planer_runtime_secrets:/app/runtime-secrets:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
expose:
|
||||
- "3000"
|
||||
read_only: true
|
||||
@@ -63,7 +68,6 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
migrate:
|
||||
profiles: ["maintenance"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -72,11 +76,16 @@ services:
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:?}
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${MIGRATION_DATABASE_URL:?}
|
||||
APP_DATABASE_PASSWORD: ${APP_DATABASE_PASSWORD:?}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-kita_planer}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kita_planer}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||
APP_DATABASE_PASSWORD_FILE: /app/runtime-secrets/database-password
|
||||
UPLOAD_DIR: /app/uploads/announcements
|
||||
command: ["node", "scripts/initialize-production.mjs"]
|
||||
volumes:
|
||||
- kita_planer_uploads:/app/uploads
|
||||
- kita_planer_next_cache:/app/runtime-cache
|
||||
- kita_planer_runtime_secrets:/app/runtime-secrets
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -103,4 +112,5 @@ volumes:
|
||||
kita_planer_postgres_data:
|
||||
kita_planer_uploads:
|
||||
kita_planer_next_cache:
|
||||
kita_planer_runtime_secrets:
|
||||
kita_planer_pgadmin_data:
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
# Kita-Planer – Funktionsübersicht und aktueller Stand
|
||||
|
||||
## Ergänzung vom 09.10.2026: Notdienstalarm
|
||||
|
||||
Der Notdienstalarm ist inzwischen umgesetzt. Admins und Koordination alarmieren die eingeteilte Familie für einen ganzen Tag oder ein Zeitfenster. Persönliche Rückmeldelinks erzeugen eine gemeinsame Haushaltsantwort; Dashboard-Hinweise, eine 30-Minuten-Frist, Klärungsbedarf, verspätete Freigabe, telefonische Korrektur, Stornierung, Versandstatus und Hintergrundprüfung sind angebunden. Aktive Alarme sperren Planumzuordnung und Diensttausche. Siehe [NOTDIENSTALARM.md](NOTDIENSTALARM.md) für Bedienung und benötigte Hosting-Konfiguration.
|
||||
|
||||
Die folgende ursprüngliche Bestandsaufnahme bleibt als historischer Stand erhalten; darin als offen bezeichnete Alarmfunktionen sind durch diese Ergänzung überholt.
|
||||
|
||||
Stand: **07.10.2026**
|
||||
|
||||
Geprüfter Ausgangsbranch: `codex/notdienst-swaps`
|
||||
|
||||
Geprüfter Commit: `ec3f4f933d42068c9d8446dc95de9815d5779238`
|
||||
|
||||
Dokumentationsbranch: `codex/funktionsinventar`
|
||||
|
||||
**Ergänzung vom 07.10.2026:** Auf `codex/personalbereich` wurde anschließend der Personalbereich für Arbeitszeit, Urlaub und Freizeitausgleich umgesetzt. Diese Bestandsaufnahme bleibt als Beschreibung des Ausgangsstands erhalten; der neue Umfang, Berechnungsregeln, Einrichtung und aktuelle Prüfungen stehen in [PERSONALBEREICH.md](PERSONALBEREICH.md). Die Aussagen unten zu fehlender Zeiterfassung, Accountverknüpfung und Tests beziehen sich auf den Ausgangscommit.
|
||||
|
||||
**Ergänzung vom 08.10.2026:** Der Personalbereich enthält jetzt auch Aktivierungsmails, Antrags- und Entscheidungshinweise sowie eine Abschalteinstellung im Fachkraftprofil. Der aktuelle Umfang und die Versandkonfiguration stehen ebenfalls in [PERSONALBEREICH.md](PERSONALBEREICH.md).
|
||||
|
||||
## 1. Grundlage der Prüfung
|
||||
|
||||
Diese Bestandsaufnahme beschreibt die im Repository vorhandenen Produktfunktionen. Geprüft wurden Seiten und Navigation, Formulare und Dialoge, Server Actions, API-Routen, Datenmodell, E-Mail-Anbindung und Betriebsdateien. Die Planung in `planung.md`, die UI-Liste in `UI-TODO.md` und der frühere Live-Testplan wurden mit dem aktuellen Code abgeglichen.
|
||||
|
||||
**„Vorhanden“ bedeutet hier: Oberfläche und zugehörige Verarbeitung sind im Code angebunden. Es bedeutet keine Bestätigung durch einen aktuellen Live-Test.** Es wurden keine Produktionsdaten verändert, keine E-Mails verschickt und keine Datenbankänderungen ausgeführt. Die tatsächliche Zustellung von E-Mails, das Verhalten im Browser und die produktive Konfiguration bleiben gesondert zu prüfen.
|
||||
|
||||
Status:
|
||||
|
||||
| Kennzeichnung | Bedeutung |
|
||||
| --- | --- |
|
||||
| Vorhanden | Im aktuellen Code als erreichbarer Ablauf umgesetzt. |
|
||||
| Teilweise | Funktion vorhanden, aber mit konkreten Einschränkungen oder fehlender Anbindung. |
|
||||
| Vorbereitet | Datenmodell, Komponente oder Aktion vorhanden; vollständiger Nutzerablauf fehlt. |
|
||||
| Offen | In der Planung vorgesehen, im geprüften Code nicht umgesetzt. |
|
||||
|
||||
## 2. Gesamtüberblick
|
||||
|
||||
Die Anwendung besitzt bereits eine breite MVP-Grundlage für mehrere unabhängige Kitas: Registrierung, Einrichtung, Familien und Kinder, Notdienst-Verfügbarkeiten und Planung, Diensttausch, Kalender, Mitbringlisten, Abwesenheiten, Kontaktdaten und Superadmin-Support. Einige beworbene Abläufe sind jedoch noch nicht vollständig nutzbar, insbesondere Alarmierung, Eltern-Rückmeldungen zu Events und die Anzeige des Schwarzen Bretts für Eltern.
|
||||
|
||||
| Bereich | Stand | Wichtigste Einschränkung |
|
||||
| --- | --- | --- |
|
||||
| Öffentliche Website und Kontakt | Vorhanden | Impressum und Datenschutz enthalten Platzhalter. |
|
||||
| Registrierung, Login, Kita-Einrichtung | Vorhanden | Kein selbstständiger Passwort-Reset; Modulschalter wirken nur teilweise. |
|
||||
| Dashboard | Vorhanden | News-, RSVP- und Alarm-Komponenten sind nicht eingebunden. |
|
||||
| Profil und eigene Kinder | Vorhanden | Persönlicher Name, E-Mail und Rolle sind im Profil nur lesbar. |
|
||||
| Familienverwaltung und feste Ämter | Vorhanden | Kein vollständiger Einladungs-Support mit erneutem Versand/Widerruf. |
|
||||
| Notdienst-Verfügbarkeiten | Vorhanden | Wochenenden und Schließtage werden nur in der Oberfläche ausgeschlossen. |
|
||||
| Notdienst-Planung | Vorhanden | Keine automatische Erinnerung und keine E-Mail bei Erstveröffentlichung. |
|
||||
| Notdienst-Tausch | Vorhanden | Nur Elternrolle kann neue Tauschanfragen starten. |
|
||||
| Notdienst-Alarmierung | Vorbereitet | Auslösebutton ist auf keiner Seite eingebunden. |
|
||||
| Kalender und Mitbringlisten | Vorhanden | Keine Kollisionsprüfung für Raumbuchungen; Mitbringsel nicht direkt bearbeitbar. |
|
||||
| Event-Teilnahme/RSVP | Teilweise | Verwaltung vorhanden, Eltern-Eingabe nicht in eine Seite eingebunden. |
|
||||
| Abwesenheiten | Vorhanden | Gruppenfilter verwendet eine künstliche Zuordnung. |
|
||||
| Schwarzes Brett | Teilweise | Veröffentlichung vorhanden, Elternanzeige und Lesestatus fehlen im aktuellen Dashboard. |
|
||||
| Adressbuch | Vorhanden | Opt-in gilt für Kontaktdaten; weitere Haushaltsdaten bleiben sichtbar. |
|
||||
| ErzieherInnen-Stammdaten | Vorhanden | Stammdatensatz und Benutzerrolle `ERZIEHER` sind getrennte Konzepte. |
|
||||
| Superadmin-Verwaltung | Vorhanden | Einladungszähler verwenden ein anderes Modell als der reale Einladungsablauf. |
|
||||
| Betrieb und Qualität | Teilweise | TypeScript erfolgreich, ESLint mit Fehlern; keine automatisierte Testsuite gefunden. |
|
||||
|
||||
## 3. Rollen und Zugriffsbereiche
|
||||
|
||||
| Rolle | Aktuelle Möglichkeiten |
|
||||
| --- | --- |
|
||||
| `SUPERADMIN` | Systemweite Kita-Übersicht, Kita-Diagnosen, Support-Aktionen und Audit-Protokoll. Standardmäßig ohne Kita-Zuordnung und mit eigener Oberfläche unter `/admin`. |
|
||||
| `ADMIN` | Verwaltung der eigenen Kita: Familien, Ämter, ErzieherInnen, Dienste, Meldungen, Kalender und Notdienst. Mit eigenem Haushalt auch Elternfunktionen nutzbar. |
|
||||
| `KOORDINATOR` | Notdienstplanung, Kalenderverwaltung, Event-Anmeldungen und Abwesenheitsübersicht. Mit eigenem Haushalt auch Verfügbarkeiten eintragbar. |
|
||||
| `ERZIEHER` | Dashboard, Kalender, Adressbuch, Profil und Abwesenheitsübersicht. Kein Zugriff auf die Notdienst-Eingabeseite. |
|
||||
| `ELTERN` | Eigener Haushalt/Kinder, Verfügbarkeiten, Kalender, Terminanfragen, Mitbringsel, Diensttausch, Abwesenheitsmeldungen und Adressbuch. |
|
||||
|
||||
Navigation und serverseitige Rollenprüfungen sind vorhanden. Die Rolle `ERZIEHER` existiert im Datenmodell und wird in Berechtigungen verwendet. Die ErzieherInnen-Verwaltung erstellt jedoch nur Namen als Stammdaten und keine Benutzerkonten. Ein regulärer Dialog zur Einladung von Erzieher-Accounts oder zur Vergabe von Admin-/Koordinatorrollen wurde nicht gefunden.
|
||||
|
||||
Quellen: [Navigation](../src/components/dashboard/sidebar-nav.tsx), [Zugriffsprüfungen](../src/lib/auth-utils.ts), [Datenmodell](../prisma/schema.prisma).
|
||||
|
||||
## 4. Funktionsinventar nach Bereich
|
||||
|
||||
### 4.1 Öffentliche Website und Kontakt
|
||||
|
||||
- Startseite mit Produktbeschreibung, Funktionsvorschauen, Rollenbeschreibung, Einstieg in Registrierung und Login sowie Kontaktbereich.
|
||||
- Fünf öffentliche Detailseiten: Notdienst-Planer, Kita-Kalender, Eltern-Adressbuch, Krankmeldung und Schwarzes Brett.
|
||||
- Kontaktformular für allgemeine Fragen, Demo-Anfragen und individuelles Setup.
|
||||
- Eingaben: Name, optionale Kita, E-Mail-Adresse, Anfrageart und Nachricht.
|
||||
- Versand einer Anfrage an den konfigurierten Kontakt-Empfänger und einer Bestätigung an die anfragende Person; passende Antwortadressen werden gesetzt.
|
||||
- Bot-Schutz durch ein unsichtbares Zusatzfeld und ein Limit von fünf Kontaktanfragen je IP-Adresse pro Stunde. Das Limit liegt im Arbeitsspeicher des jeweiligen Prozesses.
|
||||
- Sitemap, Robots-Datei und Metadaten für öffentliche Seiten.
|
||||
- Optionales Umami-Tracking über Umgebungsvariablen, auf eine Liste öffentlicher Seiten beschränkt. Suchparameter und URL-Fragmente werden ausgeschlossen; Do-Not-Track wird berücksichtigt.
|
||||
- Impressum und Datenschutzerklärung sind als Seiten vorhanden, enthalten aber noch Betreiber-, Hosting- und weitere Platzhalter.
|
||||
|
||||
Quellen: [Startseite](../src/app/page.tsx), [Feature-Inhalte](../src/lib/marketing/features.ts), [Kontaktaktion](../src/actions/contact.ts), [Analytics](../src/components/analytics/umami-analytics.tsx), [Impressum](../src/app/impressum/page.tsx), [Datenschutz](../src/app/datenschutz/page.tsx).
|
||||
|
||||
### 4.2 Registrierung, Anmeldung und Einrichtung
|
||||
|
||||
- Gründerregistrierung mit Vorname, Nachname, E-Mail-Adresse, Passwort und verpflichtender Datenschutzannahme.
|
||||
- Prüfung auf bereits verwendete E-Mail-Adressen und Passwort-Hashing mit bcrypt.
|
||||
- Gründer erhält zunächst die Adminrolle ohne Kita-Zuordnung und wird direkt angemeldet.
|
||||
- Kita-Einrichtung mit Name, eindeutigem Slug, Aktivierung von Notdienst/Kalender/Adressbuch und Mindestzahl an Verfügbarkeiten pro Kind.
|
||||
- Bei aktivem Notdienstmodul ist mindestens ein Verfügbarkeitstag erforderlich; die Einrichtung erlaubt höchstens 31.
|
||||
- Kita-Anlage und Zuordnung des Gründers erfolgen in einer Datenbanktransaktion; erneute Einrichtung desselben Kontos wird abgefangen.
|
||||
- Login mit E-Mail und Passwort, Rückleitung zu einer internen Zielseite und allgemeine Fehlermeldung bei falschen Zugangsdaten.
|
||||
- Abmeldung über Desktop-Navigation und mobiles Menü.
|
||||
- Nach zehn Fehlversuchen wird das betreffende Konto für 15 Minuten gesperrt; erfolgreicher Login setzt den Zähler zurück.
|
||||
- JWT-basierte Sessions mit Benutzer-, Kita-, Familien- und Rolleninformationen. Rolle und Zuordnungen werden bei weiteren Session-Aufrufen mit der Datenbank abgeglichen.
|
||||
- Geschützte Seiten leiten nicht angemeldete Personen zum Login und Gründer ohne Kita zur Einrichtung weiter.
|
||||
- Datenschutzannahme wird mit Zeitpunkt und Versionskennung gespeichert. Die Sonderseite für fehlende Zustimmung verweist auf Support; sie enthält kein Formular zum Nachholen der Zustimmung.
|
||||
|
||||
**Einschränkungen:** Kein Passwort-vergessen-/Passwort-ändern-Ablauf. Die Gründerregistrierung setzt `emailVerifiedAt` unmittelbar, ohne Bestätigungsmail. Ein allgemeines IP-Limit für Login oder Registrierung ist nicht angebunden; das Kontaktformular hat sein eigenes Limit.
|
||||
|
||||
Quellen: [Registrierung](../src/app/register/actions.ts), [Einrichtung](../src/app/onboarding/actions.ts), [Login](../src/app/login/actions.ts), [Authentifizierung](../src/auth.ts), [Proxy](../src/proxy.ts), [Consent-Seite](../src/app/onboarding/consent/page.tsx).
|
||||
|
||||
### 4.3 Dashboard und Navigation
|
||||
|
||||
- Begrüßung mit Kita-Namen, Benutzernamen und aktuellem Datum.
|
||||
- Kennzahlen zu Familien, aktiven Kindern, eingetragenen Notdienst-Verfügbarkeiten des Folgemonats und rechnerisch heute anwesenden Kindern.
|
||||
- Direktes Melden einer Kinderabwesenheit und Übersicht der laufenden/bevorstehenden eigenen Abwesenheiten.
|
||||
- Schnellzugriffe auf Familienverwaltung, Kalender, Notdienstplanung und Adressbuch. Die drei letztgenannten berücksichtigen die Modulschalter bei der Anzeige.
|
||||
- Hinweisdialog bei unvollständigem Profil: Telefon, Adresse sowie gegebenenfalls Haushalt und Kinder. Fortschritt wird angezeigt; der Hinweis kann im lokalen Browser dauerhaft ausgeblendet werden.
|
||||
- Rollenabhängige Seitenleiste und ein auf kleinen Bildschirmen aufklappbares Menü.
|
||||
|
||||
**Einschränkungen:** Die Notdienstkennzahl zählt Verfügbarkeitseinträge, keine tatsächlich besetzten Plantage. Schnellzugriffe auf Verwaltung werden nicht vollständig nach Rolle ausgeblendet; die Zielseiten besitzen Rollenprüfungen. Die Notdienst-Badge `4` in der Seitenleiste ist fest hinterlegt.
|
||||
|
||||
Quellen: [Dashboard](../src/app/dashboard/page.tsx), [Profilhinweis](../src/components/OnboardingDialog.tsx), [Dashboard-Rahmen](../src/components/dashboard/dashboard-shell.tsx).
|
||||
|
||||
### 4.4 Profil, Haushalt und eigene Kinder
|
||||
|
||||
- Anzeige von Name, E-Mail-Adresse, Rolle, Kita, Haushalt, angelegtem Konto und zugewiesenen festen Ämtern.
|
||||
- Bearbeiten von Telefonnummer, Straße, Postleitzahl und Ort.
|
||||
- Adressbuchfreigabe erteilen oder zurückziehen; Zeitpunkt der Freigabe wird gespeichert.
|
||||
- Eigenen Haushalt anlegen, wenn noch keiner zugeordnet ist.
|
||||
- Eigene Kinder anlegen, bearbeiten und löschen; Vor-/Nachname sowie optionales Geburtsdatum.
|
||||
- Bei Eingabe eines Geburtsdatums: gültiges Datum, nicht in der Zukunft, höchstens sieben Jahre zurück.
|
||||
- Datenänderungen an Kindern werden auf den eigenen Haushalt und die eigene Kita begrenzt.
|
||||
- Eigenes Konto nach Bestätigung löschen. Bestehen aktuelle oder zukünftige veröffentlichte Notdienstpflichten des Haushalts, wird die Selbstlöschung abgelehnt.
|
||||
- Beim letzten Benutzer eines Haushalts wird die Familie samt abhängigen Kindern gelöscht; bei weiteren Benutzern bleibt der Haushalt bestehen.
|
||||
|
||||
**Einschränkungen:** Name, E-Mail und Rolle lassen sich im persönlichen Profil nicht ändern. Eine Sperre der Kinderlöschung bei bestehenden Notdienstpflichten ist in `deleteMyChild` nicht vorhanden, obwohl die Kontolöschung diese Pflichten prüft.
|
||||
|
||||
Quellen: [Profilseite](../src/app/dashboard/profil/page.tsx), [Profilaktionen](../src/app/dashboard/profil/actions.ts).
|
||||
|
||||
### 4.5 Familienverwaltung, Eltern-Einladungen und feste Ämter
|
||||
|
||||
- Adminübersicht aller Familien mit Eltern und Kindern; Suche nach Familie, Eltern, E-Mail und Kindern.
|
||||
- Umschalten zwischen Tabelle und Karten.
|
||||
- Neue Familie mit einem oder zwei Elternteilen sowie einem bis zehn Kindern anlegen.
|
||||
- Familienname, Namen/E-Mail-Adressen der Eltern und Kindernamen bearbeiten.
|
||||
- Zweiten Elternteil ergänzen, neue Kinder hinzufügen und Kinder entfernen. Der Bearbeitungsablauf verlangt mindestens ein verbleibendes Kind und höchstens zwei Elternteile.
|
||||
- Neue Elternkonten erhalten die Rolle `ELTERN` und einen Einladungslink zur Passwortvergabe mit sieben Tagen Gültigkeit.
|
||||
- Aktivierung prüft Link, Ablaufdatum, Passwortlänge, Passwortbestätigung und Datenschutzannahme; optionales Adressbuch-Opt-in.
|
||||
- Nach Aktivierung werden Passwort und Zustimmungen gespeichert, der Token gelöscht und die Person direkt angemeldet.
|
||||
- Die Familienliste zeigt anhand der Kontoverifikation, ob eine Einladung noch offen ist.
|
||||
- Feste Ämter mit Name und optionaler Beschreibung anlegen und löschen.
|
||||
- Ein oder mehrere Ämter an einzelne Benutzer vergeben und Zuordnungen entfernen; doppelte identische Zuordnungen werden verhindert.
|
||||
- Ämter sind im Profil und Adressbuch sichtbar.
|
||||
|
||||
**Einschränkungen:** Kein regulärer Dialog zum erneuten Versand oder Widerruf einer Einladung. Einladungen werden im tatsächlichen Ablauf in `VerificationToken` gespeichert. Das separate Modell `Invitation` ist dafür nicht angebunden. Scheitert der Mailversand nach der Datenanlage, bleibt die Familie angelegt und eine Fehlermeldung wird zurückgegeben. Eine vollständige Familienlöschung durch Kita-Admins ist nicht angeboten.
|
||||
|
||||
Quellen: [Familienaktionen](../src/app/dashboard/families/actions.ts), [Familienliste](../src/app/dashboard/families/_components/families-list-client.tsx), [Einladungsaktivierung](../src/app/invite/%5Btoken%5D/actions.ts), [Ämter](../src/app/dashboard/families/duty-actions.ts).
|
||||
|
||||
### 4.6 Notdienst-Verfügbarkeiten für Eltern
|
||||
|
||||
- Eingabe für den Folgemonat, bezogen auf aktive Kinder des eigenen Haushalts.
|
||||
- Mindestzahl an ausgewählten Tagen entspricht Anzahl der Kinder × Kita-Einstellung.
|
||||
- Bereits gespeicherte Haushaltseinträge werden geladen und können erneut gespeichert werden.
|
||||
- Monatskalender mit Terminen, Kategorien, Veranstaltungsdetails und auswählbaren Verfügbarkeitstagen.
|
||||
- Die Oberfläche lässt Montag bis Freitag zu und sperrt bestätigte Schließtage.
|
||||
- Auswahlfortschritt und Rückmeldung bei zu wenigen Tagen.
|
||||
- Die ausgewählten verschiedenen Tage werden beim Speichern reihum auf die angegebenen Kinder verteilt; bestehende Einträge dieser Kinder im Zielmonat werden ersetzt.
|
||||
- Eingabefrist ist im Code fest: bis einschließlich 26. des aktuellen Monats, gesperrt ab dem 27.
|
||||
- Ohne aktive eigene Kinder erscheint ein Hinweis mit Verweis zum Profil.
|
||||
|
||||
**Einschränkungen:** Die Server Action prüft Datumsformat, Zielmonat, Mindestzahl und Haushaltszugehörigkeit, aber keine Wochenenden oder Schließtage. Die Frist ist nicht im Einstellungsdialog konfigurierbar. Abwesenheitsmeldungen werden bei den Verfügbarkeiten nicht automatisch berücksichtigt.
|
||||
|
||||
Quellen: [Elternseite](../src/app/dashboard/notdienst/page.tsx), [Kalendereingabe](../src/app/dashboard/notdienst/notdienst-form.tsx), [Speichern](../src/app/dashboard/notdienst/actions.ts), [Fristregel](../src/lib/date-utils.ts).
|
||||
|
||||
### 4.7 Notdienst-Planungszentrale
|
||||
|
||||
- Admins und Koordinatoren können über Monat/Jahr navigieren; Vorgabe ist der Folgemonat.
|
||||
- Übersicht von Haushalten, Kindern, Verfügbarkeitstagen, Haushalten mit/ohne Rückmeldung und Planstatus.
|
||||
- Automatische Erzeugung eines monatlichen Entwurfs; ein bestehender Monatsplan verhindert erneute Generierung.
|
||||
- Für jeden Montag bis Freitag wird aus verfügbaren Kindern ein Haushalt ausgewählt, der im bisherigen Monatsdurchlauf möglichst selten eingeteilt wurde. Bei Gleichstand kommt Zufall zum Einsatz; der Zähler startet jeden Monat neu.
|
||||
- Tage ohne Verfügbarkeiten bleiben unbesetzt.
|
||||
- Vorschau und manuelle Zuordnung zu einem aktiven Kind der Kita; Tage können auch geleert werden.
|
||||
- Entwurf veröffentlichen; veröffentlichte Einteilungen erscheinen im Kalender.
|
||||
- Veröffentlichte Pläne nachbearbeiten, solange der betreffende Tag nicht vergangen ist.
|
||||
- Nach manuellen Änderungen eines veröffentlichten Plans werden E-Mails an betroffene Elternkonten der Rolle `ELTERN` angestoßen; betroffene offene Tauschanfragen werden ungültig.
|
||||
- Entwurfsplan mit Bestätigungsdialog zurücksetzen und löschen. Veröffentlichte Pläne werden serverseitig vom Reset ausgeschlossen.
|
||||
- Kita-Einstellungen für Mindestverfügbarkeiten und Erinnerungs-Vorlauf speichern.
|
||||
- Manuell Erinnerungen an Haushalte versenden, die im ausgewählten Monat gar keinen Verfügbarkeitseintrag haben.
|
||||
- „Als PDF exportieren“ öffnet den Browser-Druckdialog (`window.print()`); die PDF-Datei entsteht über die Druckfunktion des Browsers.
|
||||
|
||||
**Einschränkungen:** Keine garantierte optimale oder über mehrere Monate ausgeglichene Verteilung. Die Generierung berücksichtigt keine Feiertage, Schließtage, Teamtage oder Kinderabwesenheiten. Ein Erinnerungs-Vorlauf wird gespeichert, aber kein zeitgesteuerter Job verwendet ihn. Haushalte mit zu wenigen Einträgen erhalten über diese Erinnerungsaktion keine Erinnerung, sobald wenigstens ein Eintrag vorliegt. Beim erstmaligen Veröffentlichen wird keine Zuteilungsmail versendet; die entsprechende E-Mail-Vorlage allein ist kein angebundener Versand.
|
||||
|
||||
Quellen: [Planungsseite](../src/app/dashboard/notdienst/plan/page.tsx), [Planaktionen](../src/app/dashboard/notdienst/plan/actions.ts), [Planoberfläche](../src/app/dashboard/notdienst/plan/plan-view.tsx).
|
||||
|
||||
### 4.8 Notdienst-Tausch zwischen Familien
|
||||
|
||||
- Eltern können im Kalender einen fremden Notdienst anfragen und einen eigenen veröffentlichten Dienst zum Tausch anbieten; optionale Nachricht bis 500 Zeichen.
|
||||
- Beide Dienste müssen derselben Kita angehören, veröffentlicht und heute oder zukünftig sein. Ein Tausch im eigenen Haushalt wird abgewiesen.
|
||||
- Dienste mit wartender oder bestätigter Alarmierung sind vom Tausch ausgeschlossen.
|
||||
- Bereits beteiligte Dienste mit offenen Tauschanfragen können keine weitere offene Anfrage erhalten.
|
||||
- Der angefragte Haushalt erhält eine E-Mail und sieht die offene Anfrage im Kalender.
|
||||
- Angefragter Haushalt kann annehmen oder ablehnen; anfragender Haushalt kann zurückziehen.
|
||||
- Annahme vertauscht beide Kinder-Zuordnungen in einer Transaktion, speichert die Entscheidung und storniert weitere betroffene offene Anfragen.
|
||||
- Annahme und Ablehnung lösen Benachrichtigungen an beide Familien aus; Rücknahme speichert den Status ohne eigenen Mailversand.
|
||||
- Kalender und Notdienstansichten werden nach Änderungen neu validiert.
|
||||
|
||||
**Einschränkungen:** Neue Anfragen sind auf die exakte Rolle `ELTERN` beschränkt; Eltern mit Admin-/Koordinatorrolle können sie nicht starten. Die Oberfläche zeigt offene Anfragen, keine vollständige abgeschlossene Tauschhistorie. Eine erneute Verfügbarkeitsprüfung für die getauschten Tage erfolgt nicht.
|
||||
|
||||
Quellen: [Tauschaktionen](../src/app/dashboard/kalender/actions.ts), [Kalender und Anfragen](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Tauschdialog](../src/app/dashboard/kalender/_components/termin-list.tsx).
|
||||
|
||||
### 4.9 Notdienst-Alarmierung
|
||||
|
||||
Die am 09.10.2026 abgestimmte Erweiterung ist in [NOTDIENSTALARM_PLAN.md](NOTDIENSTALARM_PLAN.md) beschrieben. Sie ist zunächst geplant; der folgende Abschnitt beschreibt den vorhandenen Code.
|
||||
|
||||
Vorbereitet sind:
|
||||
|
||||
- Server Action zum Auslösen eines Alarms durch Admin/Koordinator für eine Einteilung.
|
||||
- Kita-Prüfung und Abweisung einer bereits alarmierten Einteilung.
|
||||
- Eigene Alarmdatensätze und E-Mails für die Elternkonten der eingeteilten Familie mit Rolle `ELTERN`.
|
||||
- Bestätigungsseite unter `/alert/[token]`; der Link erlaubt die Bestätigung ohne Login.
|
||||
- Statuswechsel von `PENDING` auf `CONFIRMED` mit Zeitstempel; erneute Bestätigung wird abgewiesen.
|
||||
- Vorbereitete Button-Komponente für das Auslösen.
|
||||
|
||||
**Aktueller Stand:** `AlertButton` wird von keiner Seite eingebunden. Ein erreichbarer Auslöseablauf und eine laufende Statusübersicht für die Koordination fehlen damit in der aktuellen Oberfläche. Das Datenmodell kann eine erkrankte Fachkraft und Notizen speichern, aber die Aktion nimmt nur die Einteilungs-ID entgegen. Eine Auswahl der Fachkraft, Alarmstornierung, Eskalation oder zeitliche Token-Gültigkeit ist nicht angebunden.
|
||||
|
||||
Quellen: [Alarmaktion](../src/app/dashboard/actions.ts), [ungebundener Alarmbutton](../src/app/dashboard/alert-button.tsx), [Bestätigung](../src/app/alert/%5Btoken%5D/actions.ts).
|
||||
|
||||
### 4.10 Kalender, Terminanfragen und Mitbringlisten
|
||||
|
||||
- Gemeinsamer Kalender für bestätigte Termine, eigene ausstehende Terminanfragen, geplante Elterndienste und veröffentlichte Notdienste.
|
||||
- Listen-, Monats- und Agendaansicht; Monatsnavigation und Sprung zu heute.
|
||||
- Tagesdialog mit Termindetails, Diensten und Mitbringlisten.
|
||||
- Suche nach Titel/Beschreibung und bei Diensten nach Familie beziehungsweise Kind.
|
||||
- Kategorienfilter und Umschaltung zwischen eigenen und allen Diensten; reguläre Kita-Termine bleiben auch im eigenen Filter sichtbar.
|
||||
- Kategorien: Kita-Fest, Schließtag, Teamtag, Mitmach-Tag, Elternabend, Mitgliederversammlung, Elterncafé, interner/externer Geburtstag und Sonstiges.
|
||||
- Terminanfrage mit Titel, Beschreibung, Kategorie, Anfang/Ende und Ganztagsangabe.
|
||||
- Eigene Anfragen werden als ausstehend angezeigt; Admins/Koordinatoren können ausstehende Anfragen bestätigen oder mit Begründung ablehnen.
|
||||
- Admins/Koordinatoren können bestätigte Termine direkt anlegen, bearbeiten und endgültig löschen.
|
||||
- Datumsprüfung: Beginn nicht in der Vergangenheit, Ende nicht vor Beginn.
|
||||
- Mitbringliste je Termin aktivieren/deaktivieren; Textbeiträge hinzufügen.
|
||||
- Eigene Beiträge löschen; Admins/Koordinatoren können auch fremde Beiträge löschen.
|
||||
- Verweise aus Dienst-/Notdienstterminen in die entsprechenden Verwaltungsseiten; Diensttausch über den Kalender.
|
||||
|
||||
**Einschränkungen:** Ausstehende Anfragen blockieren keinen Zeitraum durch eine serverseitige Kollisionsprüfung. Eine Anfrage ist für andere Eltern nicht allgemein als reservierter Slot sichtbar. Mitbringbeiträge lassen sich nicht direkt bearbeiten, sondern nur löschen und neu anlegen. Es wurde kein Kalenderabo, ICS-Export oder Serientermin-Ablauf gefunden.
|
||||
|
||||
Quellen: [Kalenderseite](../src/app/dashboard/kalender/page.tsx), [Kalenderansichten](../src/app/dashboard/kalender/_components/calendar-overview-client.tsx), [Terminaktionen](../src/app/dashboard/kalender/actions.ts), [Mitbringliste](../src/app/dashboard/kalender/_components/mitbringsel-list.tsx).
|
||||
|
||||
### 4.11 Event-Anmeldungen und RSVP
|
||||
|
||||
- Admins/Koordinatoren können Termine mit Teilnahmeabfrage, optionaler Rückmeldefrist und Hinweisen für Teilnehmende anlegen/bearbeiten.
|
||||
- Verwaltungsseite listet Termine samt Status und zeigt pro Event Zusagen, Absagen und ausstehende Antworten.
|
||||
- Kindbezogene Teilnehmerliste über alle aktiven Kinder der Kita.
|
||||
- Server Action für Zu-/Absage eines eigenen aktiven Kindes, inklusive Prüfung von Kita, Haushalt, bestätigtem Termin und Frist.
|
||||
- Fehlt eine eigene Frist, gilt der Eventbeginn als Rückmeldeschluss. Eine bestehende Antwort kann bis dahin überschrieben werden.
|
||||
- Teilnehmerhinweise werden im Kalender bei Admin/Koordinator und bei einem Haushalt mit mindestens einer Zusage angezeigt.
|
||||
|
||||
**Aktueller Stand:** Die Eltern-Eingabekomponente `RsvpActionCard` existiert, wird aber von keiner Seite verwendet. Eltern besitzen deshalb im aktuellen Seitenaufbau keinen eingebundenen Zu-/Absagedialog. Außerdem wird `participantInfo` bereits an den Kalender-Client übertragen und nur bei der Darstellung ausgeblendet; die Anzeigeprüfung ist keine serverseitige Zugriffsbeschränkung für diese Information.
|
||||
|
||||
Quellen: [RSVP-Aktionen](../src/actions/rsvp.ts), [Event-Verwaltung](../src/app/dashboard/admin/kalender/admin-calendar-manager.tsx), [ungebundene Elternkomponente](../src/app/dashboard/rsvp-action-card.tsx), [Kalenderdaten](../src/app/dashboard/kalender/page.tsx).
|
||||
|
||||
### 4.12 Krank- und Abwesenheitsmeldungen
|
||||
|
||||
- Eigenes aktives Kind aus dem Haushalt auswählen und für einen Tag oder Zeitraum abmelden.
|
||||
- Gründe: Krankheit, Urlaub und Sonstiges; optionale Notiz bis 500 Zeichen.
|
||||
- Ende darf nicht vor dem Beginn liegen; sich überschneidende Meldungen desselben Kindes werden abgewiesen.
|
||||
- Eigene laufende/bevorstehende Meldungen anzeigen und löschen.
|
||||
- Tagesübersicht für Admin, Koordinator und Erzieherrolle mit Kind, Familie, Grund, Zeitraum und Notiz.
|
||||
- Tagesnavigation zurück/vor/heute und freie Datumswahl.
|
||||
- Grundfilter, Kennzahlen und Hinweise zu Wochenende, Schließtag/Teamtag sowie Tagen mehr als 30 Tage in der Zukunft.
|
||||
- Abwesenheiten sind nicht als echte Anwesenheitserfassung mit Check-in/Check-out implementiert; „anwesend“ ist aus Kinderzahl minus Meldungen abgeleitet.
|
||||
|
||||
**Einschränkungen:** Gruppen „Krippe“ und „Elementar“ werden aus dem ersten Zeichen des Kindernamens künstlich abgeleitet (`mockGroup`). Das Datenmodell hat keine echte Gruppenzuordnung. Die Anwesenheitszahl dieser Ansicht verwendet die gefilterten Fehlmeldungen bei weiterhin gesamter Kinderzahl und kann deshalb bei aktivem Filter irreführend sein. Eine bestehende Abwesenheit kann nur gelöscht und neu gemeldet, nicht direkt bearbeitet werden.
|
||||
|
||||
Quellen: [Abwesenheitsaktionen](../src/actions/absences.ts), [Elternformular](../src/app/dashboard/absence-card.tsx), [Tagesübersicht](../src/app/dashboard/admin/abwesenheiten/page.tsx), [Filter](../src/app/dashboard/admin/abwesenheiten/_components/absences-client.tsx).
|
||||
|
||||
### 4.13 Wöchentlicher Dienstplan
|
||||
|
||||
- Separater Dienstplan für Haushalte, unabhängig von festen personenbezogenen Ämtern und Notdienst.
|
||||
- Diensttypen mit Name/Beschreibung anlegen und löschen.
|
||||
- Wochenraster mit wählbarem Beginn und Zeitraum; Standard zwölf Wochen, maximal 104 Wochen.
|
||||
- Haushalt je Diensttyp/Kalenderwoche auswählen oder Zuordnung entfernen; Änderungen werden direkt gespeichert.
|
||||
- Kita-Zugehörigkeit von Diensttyp und Familie wird geprüft.
|
||||
- Einteilungen erscheinen im gemeinsamen Kalender.
|
||||
- Manuelle Erinnerungsaktion für noch nicht erinnerte geplante Dienste, deren Beginn in der aktuellen Woche liegt.
|
||||
- Versand an verifizierte Benutzer der Familie; erfolgreicher Versand wird mit `reminderSentAt` gespeichert. Erneute Zuordnung setzt diesen Wert zurück.
|
||||
|
||||
**Einschränkungen:** Kein automatisch laufender Erinnerungsjob. Status `DONE` und `CANCELLED` existieren im Datenmodell, aber ein regulärer Abschluss-/Stornoablauf ist nicht eingebunden. Eine Änderung setzt den Dienst wieder auf `PLANNED`. Das Löschen eines Diensttyps löscht durch die Datenrelationen auch dessen Einteilungen.
|
||||
|
||||
Quellen: [Dienstplanseite](../src/app/dashboard/admin/dienste/page.tsx), [Raster](../src/app/dashboard/admin/dienste/duty-planner.tsx), [Dienstaktionen](../src/app/dashboard/admin/dienste/actions.ts).
|
||||
|
||||
### 4.14 Schwarzes Brett, E-Mail und Anhänge
|
||||
|
||||
- Admins können eine Ankündigung mit Titel und Markdown-Inhalt erstellen; Vorschau ist im Formular vorhanden.
|
||||
- PDF-, JPG- und PNG-Anhänge bis zehn MB je Datei.
|
||||
- Speicherung der Anhangdateien auf dem Server und der Metadaten in der Datenbank.
|
||||
- Abruf über geschützte API mit Prüfung auf angemeldeten Benutzer und dieselbe Kita; keine Auslieferung über ein öffentliches Uploadverzeichnis.
|
||||
- Optionaler E-Mail-Versand an verifizierte Haushaltskonten, Erzieherkonten oder beide Gruppen. Die Auswahl betrifft E-Mail-Empfänger und wird nicht als Sichtbarkeitsgruppe der Meldung gespeichert.
|
||||
- Adminseite zeigt die letzten acht veröffentlichten Meldungen mit Autor, Zeitpunkt und Anhanganzahl.
|
||||
- Bei Dateischreibfehlern werden die neue Meldung und angefangene Dateien zurückgenommen; bei Mailfehlern bleibt die Meldung gespeichert und eine Warnung wird zurückgegeben.
|
||||
- Lesestatusmodell und Aktion zum Markieren als gelesen sind vorhanden; `NewsTicker` enthält einen vorbereiteten Meldungsdialog.
|
||||
|
||||
**Aktueller Stand:** `NewsTicker` ist nicht in das Dashboard oder eine andere Seite eingebunden. Eltern können neue Meldungen daher derzeit über den optionalen Mailversand erhalten, besitzen aber keine eingebundene Meldungsliste in der App. Eine Adminauswertung der Lesebestätigungen sowie Bearbeiten/Löschen von Meldungen sind nicht umgesetzt.
|
||||
|
||||
Quellen: [Meldungsaktionen](../src/actions/announcements.ts), [Adminseite](../src/app/dashboard/admin/news/page.tsx), [Formular](../src/app/dashboard/admin/news/news-form.tsx), [ungebundener Ticker](../src/app/dashboard/news-ticker.tsx), [Anhang-API](../src/app/api/announcements/attachments/%5Bid%5D/route.ts).
|
||||
|
||||
### 4.15 Adressbuch
|
||||
|
||||
- Interne Übersicht der Haushalte, bei denen mindestens ein Benutzer der Kontaktfreigabe zugestimmt hat.
|
||||
- Anzeige von Haushaltsname, Kindern, Elternnamen und festen Ämtern; alphabetische Gruppierung nach Haushaltsnamen.
|
||||
- E-Mail, Telefon und Adresse werden nur für den jeweiligen Benutzer mit Opt-in geladen.
|
||||
- Suche nach Familienname, Eltern-/Kindernamen oder sichtbarer E-Mail.
|
||||
- Filter für Telefonnummer, Ämter und Haushalte mit Opt-in aller zugeordneten Benutzer.
|
||||
- Detaildialog je Familie; E-Mail- und Telefonlinks starten den jeweiligen Mail-/Telefonhandler.
|
||||
- Sichtbarkeitsdialog erklärt die Freigabe und führt zum eigenen Profil.
|
||||
- Optionales Geburtsdatum eines Kindes ist im Detaildialog sichtbar.
|
||||
|
||||
**Einschränkung:** Namen und Ämter eines zweiten Elternteils ohne Kontaktfreigabe sowie Kinderinformationen sind innerhalb eines sichtbaren Haushalts weiterhin sichtbar. Die Freigabe ist technisch kein Opt-in für sämtliche Haushaltsinformationen.
|
||||
|
||||
Quellen: [Adressbuchdaten](../src/app/dashboard/adressbuch/page.tsx), [Adressbuchoberfläche](../src/app/dashboard/adressbuch/_components/directory-client.tsx).
|
||||
|
||||
### 4.16 ErzieherInnen-Stammdaten
|
||||
|
||||
- Admins können Fachkräfte mit Vor- und Nachname anlegen, bearbeiten und löschen.
|
||||
- Aktivstatus wird gepflegt und dargestellt.
|
||||
- Diese Datensätze sind keine Login-Konten; die separate Benutzerrolle `ERZIEHER` ist hiervon unabhängig.
|
||||
- Referenz für Notdienstalarme im Datenmodell vorhanden, aber derzeit keine Fachkraftauswahl im Alarmauslöser.
|
||||
|
||||
Quellen: [Erzieheraktionen](../src/app/dashboard/erzieher/actions.ts), [Erzieheroberfläche](../src/app/dashboard/erzieher/_components/erzieher-list.tsx).
|
||||
|
||||
### 4.17 Systemweite Superadmin-Verwaltung
|
||||
|
||||
- Übersicht aller Kitas mit Slug, Modulstatus, Benutzer-/Familien-/Kinderzahlen, Terminen, Abwesenheiten, Meldungen, offenen Einladungen und letztem Login.
|
||||
- Hinweise auf Benutzer ohne Kita, Kontosperren, fehlende Datenschutzzustimmung und Familien ohne Benutzer.
|
||||
- Kita-Diagnose mit Benutzerliste, Rollen, Familien, Verifikationsstatus, Consent, Fehlversuchen und Sperren; zusätzlich Zähler für Konten ohne Passwort und abgelaufene Einladungen.
|
||||
- Diagnoseaufrufe werden im Audit-Protokoll gespeichert.
|
||||
- Kita endgültig löschen nach exakter Namensbestätigung, mit kaskadierender Löschung abhängiger Datenbankdatensätze.
|
||||
- Kita-gebundene Benutzer löschen nach Bestätigung ihrer E-Mail-Adresse; dabei zugehörige Verifikationstokens entfernen.
|
||||
- Benutzer für weitere Logins sperren, entsperren oder Fehlversuche zurücksetzen. Sperrung setzt `lockedUntil` weit in die Zukunft; Entsperren löscht die Sperre und setzt den Zähler zurück.
|
||||
- Eigene und andere Superadmin-Konten sowie Konten ohne Kita werden durch diese Support-Aktionen nicht verändert.
|
||||
- Auditansicht zeigt die neuesten 200 Einträge samt Akteur, Ziel, Zeitpunkt, Aktion und Metadaten. Protokolle können auch nach Löschung der Zielobjekte bestehen bleiben.
|
||||
|
||||
**Einschränkungen:** Offene und abgelaufene Einladungen werden über `Invitation` gezählt, während die tatsächlichen Familien-Einladungen `VerificationToken` nutzen. Eine Superadmin-Benutzerlöschung entfernt nicht automatisch einen nun leeren Haushalt. Eine Sperre wird bei der Anmeldung geprüft; das sofortige Beenden bereits bestehender Sessions ist nicht umgesetzt.
|
||||
|
||||
Quellen: [Adminübersicht und Diagnose-Daten](../src/app/admin/_lib/data.ts), [Support-Aktionen](../src/app/admin/actions.ts), [Auditseite](../src/app/admin/audit/page.tsx).
|
||||
|
||||
## 5. Datenmodell und technische Querschnittsfunktionen
|
||||
|
||||
- PostgreSQL mit Prisma. Eine Kita ist ein Mandant; fachliche Daten sind über `kitaId` zugeordnet.
|
||||
- Datenmodelle für Kitas, Benutzer, Haushalte, Kinder, Fachkräfte, feste Ämter, Diensttypen/-einteilungen, Abwesenheiten, Meldungen, Anhänge, Lesestatus, Termine, Teilnahme, Mitbringsel, Notdienstpläne/-verfügbarkeiten/-einteilungen/-alarme/-tausche sowie Auditprotokolle und Tokens.
|
||||
- Eindeutigkeitsregeln unter anderem für E-Mail, Kita-Slug, Monatsplan, Einteilung je Plantag, Verfügbarkeit je Kind/Tag, Teilnahme je Event/Kind und Amt je Benutzer.
|
||||
- Eingabeprüfung überwiegend mit Zod, Schreibabläufe teilweise als Datenbanktransaktionen, Aktualisierung von Seiten nach Änderungen.
|
||||
- Gemeinsamer E-Mail-Versand über Resend mit Fehlerbehandlung. Ohne API-Key werden E-Mails nur in der Entwicklungsumgebung simuliert; diese Simulation bestätigt keine Zustellung.
|
||||
- Vorlagen für Einladung, Alarm, Verfügbarkeitserinnerung, Einteilungsänderung, Diensttausch, Wochen-Diensterinnerung, News und Kontakt. Eine weitere Vorlage für erstmalige Notdienst-Zuteilung ist vorhanden, aber nicht an die Veröffentlichung angebunden.
|
||||
- Rollen-, Kita- und Consent-Prüfungen über gemeinsame Helfer. Geschützte Dashboard-/Adminlayouts, Seiten und Aktionen setzen Zugriffsprüfungen ein; nicht jede Aktion verwendet denselben Consent-Helfer.
|
||||
- Viele Datenbankrelationen löschen abhängige Datensätze mit. Das ist nicht gleichbedeutend mit vollständiger Bereinigung von Dateien, allgemeinen Verifikationstokens oder Audit-Snapshots.
|
||||
- Der Hilfsmodus `TEST_MODE=true` liefert in `requireRole` und `requireKitaSession` künstliche Sessions und umgeht dort die regulären Prüfungen. Er ist nicht auf die Entwicklungsumgebung begrenzt.
|
||||
|
||||
Quelle: [Schema](../prisma/schema.prisma), [Mail](../src/lib/mail.ts), [Auth-Helfer](../src/lib/auth-utils.ts).
|
||||
|
||||
## 6. Seiten- und Schnittstellenverzeichnis
|
||||
|
||||
| Route | Zweck/Zugriff |
|
||||
| --- | --- |
|
||||
| `/` | Öffentliche Startseite und Kontakt. |
|
||||
| `/features/notdienst-planer` | Öffentliche Notdienst-Beschreibung. |
|
||||
| `/features/kita-kalender` | Öffentliche Kalender-Beschreibung. |
|
||||
| `/features/eltern-adressbuch` | Öffentliche Adressbuch-Beschreibung. |
|
||||
| `/features/krankmeldung` | Öffentliche Abwesenheits-Beschreibung. |
|
||||
| `/features/schwarzes-brett` | Öffentliche Meldungs-Beschreibung. |
|
||||
| `/impressum`, `/datenschutz` | Öffentliche Informationsseiten mit noch offenen Platzhaltern. |
|
||||
| `/register`, `/login` | Gründerregistrierung und Anmeldung. |
|
||||
| `/onboarding` | Kita einrichten nach Anmeldung. |
|
||||
| `/onboarding/consent` | Hinweis bei fehlender Datenschutzannahme. |
|
||||
| `/invite/[token]` | Elternkonto über Einladungslink aktivieren. |
|
||||
| `/alert/[token]` | Notdienstalarm über Link bestätigen. |
|
||||
| `/forbidden` | Hinweis bei verweigertem Zugriff. |
|
||||
| `/dashboard` | Kita-Übersicht und eigene Abwesenheiten. |
|
||||
| `/dashboard/profil` | Eigener Haushalt, Kinder, Kontakte und Kontolöschung. |
|
||||
| `/dashboard/notdienst` | Verfügbarkeiten; Eltern, Admin, Koordinator. |
|
||||
| `/dashboard/notdienst/plan` | Planung; Admin, Koordinator. |
|
||||
| `/dashboard/kalender` | Kita-Kalender und Diensttausch. |
|
||||
| `/dashboard/adressbuch` | Internes Adressbuch. |
|
||||
| `/dashboard/families` | Familien und feste Ämter; Admin. |
|
||||
| `/dashboard/erzieher` | Fachkraft-Stammdaten; Admin. |
|
||||
| `/dashboard/admin/dienste` | Wochen-Dienstplan; Kita-Admin. Die Aktion erlaubt zusätzlich Superadmin mit Kita-Kontext. |
|
||||
| `/dashboard/admin/news` | Meldungen veröffentlichen; Admin. |
|
||||
| `/dashboard/admin/kalender` | Termine und Event-Anmeldungen; Admin, Koordinator. |
|
||||
| `/dashboard/admin/abwesenheiten` | Tagesübersicht; Admin, Koordinator, Erzieherrolle. |
|
||||
| `/admin` | Systemweite Kita-Übersicht; Superadmin. |
|
||||
| `/admin/kitas/[kitaId]` | Kita-Diagnose und Benutzersupport; Superadmin. |
|
||||
| `/admin/audit` | Systemweites Audit-Protokoll; Superadmin. |
|
||||
| `/api/auth/[...nextauth]` | Auth.js-Endpunkte. |
|
||||
| `/api/announcements/attachments/[id]` | Geschützter Anhangdownload. |
|
||||
| `/sitemap.xml`, `/robots.txt` | Suchmaschinensteuerung. |
|
||||
|
||||
## 7. Betrieb, Entwicklung und Prüfstand
|
||||
|
||||
- Stack laut `package.json`: Next.js 16.2.4, React 19.2.4, TypeScript, Prisma 6, Auth.js/NextAuth v5 beta, PostgreSQL, Tailwind CSS und Resend.
|
||||
- Lokaler Entwicklungsstart führt vor Next.js automatisch `prisma db push` und den Seed aus.
|
||||
- Seed legt Demonstrationsdaten mit mehreren Rollen und fachlichen Beispielen an; regulärer Seed ist bei bereits befüllter Datenbank zurückhaltend. Ein separater Reset-Modus existiert.
|
||||
- Docker-Build und Produktionsstart sind vorhanden; Produktionsstart wendet das Schema über `prisma db push --skip-generate` an. Versionierte Prisma-Migrationen liegen nicht im geprüften Dateibestand.
|
||||
- Lokale Compose-Datei stellt PostgreSQL bereit; Coolify-Compose enthält App, PostgreSQL, pgAdmin und persistente Datenbank-/Uploadvolumes.
|
||||
- Umgebungsvariablen-Vorlage und Produktionscheckliste sind vorhanden; ihre tatsächliche Erfüllung wurde nicht auf einem Server geprüft.
|
||||
- `UPLOAD_DIR` ist persistent vorgesehen. Die Dateihilfsfunktionen hängen noch `announcements` an diesen Pfad an; bei der aktuellen Compose-Konfiguration ergibt sich `/app/uploads/announcements/announcements`.
|
||||
- Kein Testskript und keine automatisierte Testdateisuite im geprüften Repository gefunden. Demo-Seed und manueller Live-Testplan ersetzen keine automatisierten Tests.
|
||||
|
||||
### Ausgeführte technische Prüfungen
|
||||
|
||||
Die Prüfungen liefen auf dem unveränderten Ausgangscheckout mit den dort bereits installierten Abhängigkeiten. Es wurden keine Bibliotheken installiert und keine Builds oder Datenbankoperationen ausgeführt.
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| `./node_modules/.bin/tsc --noEmit --incremental false` | Erfolgreich, Exit-Code 0. |
|
||||
| `npm run lint` | Fehlgeschlagen, Exit-Code 1: zwei Fehler und neun Warnungen. |
|
||||
| Quellenprüfung von Seiten, Aktionen, Komponenten und Schema | Durchführung für die oben beschriebenen Funktionsbereiche. |
|
||||
| Aktueller Browser-/End-to-End-Test | Nicht durchgeführt. |
|
||||
| Mailzustellung und produktives Deployment | Nicht geprüft. |
|
||||
|
||||
Beide ESLint-Fehler betreffen `as any` im Testmodus von `src/lib/auth-utils.ts` (Zeilen 57 und 91). Die Warnungen betreffen ungenutzte Variablen/Imports im Seed, Kalender, Adressbuch und Abwesenheiten. Diese Bestandsaufnahme ändert keinen Anwendungscode.
|
||||
|
||||
Der vorhandene [Live-Testplan](live-testplan.md) vom 17.05.2026 dokumentiert erfolgreiche Tests von Landingpage/Kontakt, Gründerregistrierung, Einrichtung, Dashboard, Familienanlage und Teilen des Login-Ablaufs. Eltern-Einladungsaktivierung und anschließende Bereinigung sind dort noch offen. Dies sind historische Einträge, keine aktuellen Testnachweise für den geprüften Commit. Die abgehakten Punkte in [UI-TODO.md](../UI-TODO.md) sind ebenfalls historische Angaben; viele der dort ehemals fehlenden Handler sind inzwischen im Code vorhanden.
|
||||
|
||||
Quellen: [Paket und Skripte](../package.json), [Deployment](../deploy/README.md), [Produktionscheckliste](../deploy/production-checklist.md), [Seed](../prisma/seed.ts), [Coolify-Compose](../docker-compose.coolify.yml).
|
||||
|
||||
## 8. Konkrete offene Punkte für die weitere Arbeit
|
||||
|
||||
Die Reihenfolge ist ein Vorschlag aus der Bestandsaufnahme; es wurde nichts davon umgesetzt.
|
||||
|
||||
| Reihenfolge | Thema | Konkreter nächster Schritt |
|
||||
| --- | --- | --- |
|
||||
| 1 | Fehlende Elternabläufe | NewsTicker und RSVP-Eingabe an eine erreichbare Seite anbinden; Daten dafür laden. |
|
||||
| 2 | Notdienstalarm | Auslösebutton, Fachkraftauswahl und Statusanzeige in den aktuellen Seitenaufbau integrieren. |
|
||||
| 3 | Moduldeaktivierung | Modulschalter in Navigation, Seiten und Server Actions einheitlich prüfen; spätere Einstellungen anbieten. |
|
||||
| 4 | Notdienstregeln | Wochenenden/Schließtage serverseitig ausschließen und Generierung um Schließtage ergänzen; Abwesenheitsregel festlegen. |
|
||||
| 5 | Erinnerungen | Automatische Ausführung ergänzen und unterscheiden, ob gar keine oder zu wenige Verfügbarkeiten vorliegen. |
|
||||
| 6 | Erstveröffentlichung | Zuteilungsmails beim Veröffentlichen anbinden und Mailfehler sichtbar behandeln. |
|
||||
| 7 | Einladungssupport | Tokenmodell und Superadmin-Zähler vereinheitlichen; erneuten Versand, Ablauf und Widerruf erreichbar machen. |
|
||||
| 8 | Rechtevergabe | Regulären Ablauf für Koordinator-/Erzieherkonten und Rollenänderungen ergänzen. |
|
||||
| 9 | Abwesenheitsgruppen | Echte Gruppenzuordnung einführen oder den künstlichen Gruppenfilter entfernen; Kennzahlen konsistent berechnen. |
|
||||
| 10 | Buchungskonflikte | Fachliche Reservierungsregeln definieren und Überschneidungen serverseitig prüfen. |
|
||||
| 11 | Selbstverwaltung | Passwort-Reset/-Änderung und direkte Bearbeitung von Abwesenheiten/Mitbringbeiträgen ergänzen. |
|
||||
| 12 | Löschabläufe | Kinderlöschung bei bestehenden Verpflichtungen prüfen; Anhangdateien und Tokens bei Kita-/Kontolöschungen bereinigen; Audit-Aufbewahrung gesondert definieren. |
|
||||
| 13 | Zugriffsprüfungen | Testmodus an Entwicklungsumgebung binden; Consent-/Session-Prüfungen und Teilnehmerhinweise serverseitig vereinheitlichen. |
|
||||
| 14 | Technischer Zustand | Zwei ESLint-Fehler beheben, Migrationsablauf einführen und aussagekräftige Tests für die zentralen Abläufe ergänzen. |
|
||||
| 15 | Aktuelle Abnahme | Rollenbasierte Browserprüfung, Mailzustellung, Elternaktivierung und Bereinigung anhand eines aktualisierten Testplans durchführen. |
|
||||
| 16 | Öffentliche Texte | Platzhalter in Impressum/Datenschutz mit den tatsächlichen Betriebsangaben vervollständigen und Marketingversprechen mit dem implementierten Verhalten abgleichen. |
|
||||
|
||||
## 9. Nicht als fertige Produktfunktion vorhanden
|
||||
|
||||
Im geprüften Code wurden kein Abonnement-/Zahlungsablauf, keine Import-/Exportverwaltung für Familien, kein externer Kalenderabgleich, keine Push-/SMS-Alarmierung, keine echte Kita-Gruppenverwaltung und keine Zeiterfassung/Check-in-Funktion gefunden. Vorhandene Planungs- oder Marketingtexte sind dafür kein Umsetzungsnachweis.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Notdienstalarm
|
||||
|
||||
Stand: 09.10.2026 · Umsetzung auf `codex/notdienstalarm`
|
||||
|
||||
## Bedienung
|
||||
|
||||
Kita-Admins und Notdienst-Koordination finden den Alarm in der Navigation und bei eingeteilten Tagen eines veröffentlichten Notdienstplans. Der Dialog zeigt die betroffene Familie und ihre Elternadressen vor dem Auslösen. Er umfasst einen ganzen Kita-Tag oder ein Zeitfenster, eine Antwortfrist (standardmäßig 30 Minuten), eine optionale interne Fachkraftzuordnung und eine interne Notiz.
|
||||
|
||||
Nur die eingeteilte Familie wird angefragt. Jedes Elternkonto erhält einen eigenen persönlichen Rückmeldelink und einen Hinweis in der App. Die Antwort „Wir übernehmen“ oder „Wir können nicht“ muss ausdrücklich gespeichert werden und gilt für den gesamten Haushalt. Das Öffnen des Links speichert keine Antwort. Die erste gespeicherte Antwort entscheidet auch bei gleichzeitigen Antworten; Änderungen dokumentiert die Koordination.
|
||||
|
||||
| Anzeige | Bedeutung / nächster Schritt |
|
||||
| --- | --- |
|
||||
| Antwort ausstehend | Haushalt hat noch nicht geantwortet. |
|
||||
| Übernahme bestätigt | Rechtzeitige Zusage oder dokumentierte Freigabe durch Koordination. |
|
||||
| Absage – Klärung erforderlich | Alle Admins und KoordinatorInnen der Kita erhalten sofort einen Hinweis. Ersatz manuell organisieren. |
|
||||
| Klärung erforderlich – Frist abgelaufen | Nach Ablauf informiert der Hintergrundjob einmalig die Admins und KoordinatorInnen. |
|
||||
| Verspätete Zusage | Muss durch Koordination freigegeben werden; keine automatische sichere Besetzung. |
|
||||
| Klärung erforderlich | Uneindeutige frühere Einzelantworten wurden übernommen. Haushalt kontaktieren. |
|
||||
| Storniert | Haushalt wird informiert; der Link kann keine Antwort mehr speichern. |
|
||||
|
||||
Telefonische Rückmeldungen, Korrekturen, verspätete Freigaben und Stornierungen benötigen eine interne Begründung. Interne Fachkraftdaten, Notizen und Verlauf erscheinen ausschließlich in der Verwaltungsansicht und werden nicht per E-Mail übermittelt. Eltern sehen ihre eigenen Alarme; andere Haushalte und Fachkräfte haben keinen Zugriff.
|
||||
|
||||
Aktive Alarme sperren manuelle Umzuordnung und Diensttausche. Für einen Ersatz zuerst den Alarm stornieren, dann die Einteilung ändern und neu alarmieren. Ein ausstehender E-Mail-Versand lässt sich erneut versuchen, ohne die Antwortfrist neu zu starten. Die Anzeige „vom Maildienst angenommen“ bestätigt die Annahme durch den Versanddienst, nicht die Zustellung im Postfach.
|
||||
|
||||
## Zeiten und Grenzen
|
||||
|
||||
- Kita-Zeitzone: `Europe/Berlin`, einschließlich Sommer-/Winterzeit. Nicht existierende Uhrzeiten bei der Zeitumstellung werden abgelehnt.
|
||||
- Ganze Tage enden um Mitternacht zum Folgetag. Zeitfenster müssen am selben Tag liegen; Ende nach Beginn.
|
||||
- Die Antwortfrist startet beim Auslösen, beträgt 1–1440 Minuten und endet spätestens mit dem Einsatz. Alarme sind für heute und zukünftige veröffentlichte Einteilungen möglich.
|
||||
- Nach Einsatzende können Rückmeldelinks keine Antwort mehr speichern. Der letzte Status bleibt lesbar.
|
||||
- Die Übersicht zeigt bis zu 100 Alarme seit 30 Tagen vor heute. Direkte Detail-Links öffnen auch ältere Vorgänge. Im Dialog stehen Einteilungen der nächsten 93 Tage zur Wahl; das Dashboard zeigt bis zu zehn aktuelle Alarme.
|
||||
- Kein SMS-/Push-Versand und keine automatische Alarmierung anderer Familien. Notdienstmails gehören zum Alarmablauf und verwenden nicht die persönliche Einstellung für Personal-Antragsmails.
|
||||
|
||||
## Betrieb und Hintergrundjob
|
||||
|
||||
Benötigt werden die vorhandene Resend-Konfiguration (`RESEND_API_KEY`, `EMAIL_FROM`), die korrekte öffentliche App-Adresse (`NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL`/`NEXTAUTH_URL`) und neu `NOTDIENST_JOB_SECRET` mit mindestens 32 Zeichen. Ein zufälliges Secret kann beispielsweise mit `openssl rand -hex 32` erzeugt und in der Hosting-Konfiguration gespeichert werden. Das Secret gehört nicht in Git oder ins Browser-Frontend.
|
||||
|
||||
Der bestehende Docker-Produktionsstart startet `scripts/notdienst-alarm-worker.mjs`. Dieser ruft nach fünf Sekunden und danach ungefähr jede Minute lokal `POST /api/jobs/notdienstalarme` auf. Fristmeldungen entstehen dadurch auch ohne geöffneten Browser, normalerweise innerhalb einer Minute nach Fristende, zuzüglich Laufzeit und möglicher Ausfälle. Bei fehlendem Secret startet der allgemeine Produktionsstart ohne Fristjob und die Verwaltungsseite zeigt einen Hinweis; die Coolify-Vorlage verlangt das Secret. Ein zu kurzes gesetztes Secret stoppt den Produktionsstart.
|
||||
|
||||
Bei einem anderen Hosting-Verfahren muss der Worker als eigener dauerhaft überwachter Prozess gestartet werden oder ein externer Scheduler die Route mindestens einmal pro Minute per POST und `Authorization: Bearer <NOTDIENST_JOB_SECRET>` aufrufen. Nur `npm run start` startet den Worker nicht. Der Scheduler/Host muss auch nach Neustarts laufen; ein gesetztes Secret allein belegt keine laufende Fristprüfung.
|
||||
|
||||
Der Job bearbeitet bis zu 100 Fristfälle und 100 E-Mail-Aufträge je Lauf. Wiederholte und gleichzeitige Aufrufe werden abgefangen: Fristmeldungen besitzen einen eindeutigen Ereignisschlüssel, Mailaufträge eine Versandreservierung und einen stabilen Idempotenzschlüssel. Versandfehler bleiben gespeichert und werden bei späteren Läufen erneut versucht; bereits angenommene Aufträge werden nicht erneut angefragt. Rollen- und Haushaltszuordnungen werden vor dem Versand erneut geprüft. Überholte Anfragen werden übersprungen. Provider-Garantien zur Idempotenz und tatsächliche Postfachzustellung bleiben Grenzen außerhalb der Anwendung.
|
||||
|
||||
HTTP 401 bedeutet fehlende/falsche Berechtigung, 503 fehlendes/zu kurzes Job-Secret, 500 einen fehlgeschlagenen Lauf. HTTP 200 enthält die Bearbeitungszahlen einschließlich fehlgeschlagener E-Mails; die Versandübersicht und Server-/Worker-Logs sind für die Betriebsprüfung heranzuziehen. Bei Ausfall wird die Fristüberschreitung schon anhand der Uhrzeit in der Oberfläche angezeigt; E-Mail-Eskalation und Reservierungsfreigabe benötigen den nächsten erfolgreichen Joblauf.
|
||||
|
||||
## Datenbank und vorhandene Alarme
|
||||
|
||||
Das Schema ergänzt einen gemeinsamen Alarmvorgang, einzelne Empfänger und Tokens, Hinweise, Mailaufträge und einen Verlauf. Die bisherigen `NotdienstAlert`-Datensätze bleiben erhalten. Ein aktiver Alarm reserviert seine Einteilung eindeutig; Transaktionen schützen konkurrierende Antworten und Planänderungen.
|
||||
|
||||
Der bestehende Docker-Start nutzt `prisma db push --skip-generate`. Für kontrollierte SQL-Upgrades liegt alternativ [deploy/sql/notdienstalarm.sql](../deploy/sql/notdienstalarm.sql) vor. Es setzt den vollständigen Personalbereich einschließlich seiner Mail-Erweiterung voraus, ist einmalig auszuführen und enthält keine Datenlöschung. Nicht zusätzlich ausführen, wenn `db push` diese Änderungen bereits angewandt hat. Datenbanksicherung und Prüfung des Ausgangsschemas gehören zum Deployment. Die Auswahl der SQL-Alternativen erklärt die [zentrale Deployment- und Mailanleitung](../deploy/DEPLOYMENT_UND_MAILVERSAND.md).
|
||||
|
||||
Bisherige Einzelalarme werden beim Verwaltungsaufruf, Joblauf oder Aufruf eines alten Links in Gruppen pro Einteilung übernommen. Alte gültige Tokens bleiben nutzbar; Originalantworten bleiben gespeichert. Nur vollständig übereinstimmende gültige Antworten ergeben eine sichere gemeinsame Zusage. Widersprüche, fehlende/ungeklärte Empfänger oder doppelte Einzelalarme führen zu Klärungsbedarf. Bei doppelten Datensätzen eines Elternkontos wird ein Rückmeldetoken übernommen; die übrigen Originaldatensätze bleiben erhalten. Die Übernahme verschickt keine erneute Alarmanfrage.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Abschließender Prüfstand: 20 Alarmtests, 17 Personal-Service-Tests und neun Berechnungstests erfolgreich; TypeScript, ESLint der betroffenen Dateien und Produktionsbuild erfolgreich. Das additive SQL-Upgrade wurde auf einem separaten Schema der isolierten Datenbank angewandt und per Prisma mit dem Zielmodell verglichen: keine Abweichungen. Worker und Jobroute wurden lokal geprüft (401 ohne Secret, 200 mit Secret, Fristmeldung je zuständigem Konto einmalig). Die vorhandenen globalen ESLint-Fehler in `auth-utils.ts` liegen außerhalb dieser Änderung.
|
||||
|
||||
Automatisierte Alarmtests prüfen Rollen und Mandanten, gemeinsame und konkurrierende Antworten, Fristprüfung, Absagen, verspätete Freigaben, Stornierung, Tokens, Versandfehler/erneuten Versand, Altbestände sowie die Sperre vorhandener Plan- und Diensttausch-Aktionen. Sie benötigen ausdrücklich die isolierte lokale Testdatenbank `127.0.0.1:55439/kita_staff_test` und bereinigen nur ihre eigenen Test-Kitas.
|
||||
|
||||
```sh
|
||||
DATABASE_URL='postgresql://USER@127.0.0.1:55439/kita_staff_test' npm run test:alarms
|
||||
npm run test:staff
|
||||
```
|
||||
|
||||
Die Browserprüfung verwendet ausschließlich Demo-Daten und deaktivierten tatsächlichen E-Mail-Versand. Geprüft wurden Auslösen aus der Einteilung, Zeitfenster, Eltern-Dashboard-Hinweis, persönliche Rückmeldeseite mit expliziter Bestätigung und eine mobile Ansicht ohne horizontalen Überlauf. Produktive Zustellung und Deployment sind separat durchzuführen.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Notdienstalarm – abgestimmter Funktionsplan
|
||||
|
||||
Stand: 09.10.2026 · Branch `codex/notdienstalarm`
|
||||
|
||||
Status: Umgesetzt. Dieser Plan hält die abgestimmten Entscheidungen fest. Bedienung, Betriebsanforderungen und Prüfungen stehen in [NOTDIENSTALARM.md](NOTDIENSTALARM.md).
|
||||
|
||||
## Festgelegter Umfang
|
||||
|
||||
| Thema | Entscheidung |
|
||||
| --- | --- |
|
||||
| Zuständigkeit | Nur Kita-Admins und Notdienst-Koordination (`ADMIN`, `KOORDINATOR`) lösen Alarme aus und verwalten sie. |
|
||||
| Erste Empfänger | Ausschließlich die für das Datum im veröffentlichten Notdienstplan eingeteilte Familie. Alle Elternkonten dieses Haushalts werden einzeln angeschrieben. |
|
||||
| Rückmeldung | „Wir übernehmen“ oder „Wir können nicht“. Eine Antwort gilt für den gesamten Haushalt. |
|
||||
| Kanäle | E-Mail und ein deutlich sichtbarer Hinweis in der App. |
|
||||
| Antwortfrist | Standardmäßig 30 Minuten ab Auslösung; beim Auslösen anpassbar. |
|
||||
| Einsatzdauer | Wahl zwischen „Ganzer Kita-Tag“ und „Bestimmte Uhrzeiten“ mit Beginn und Ende. |
|
||||
| Fehlende Übernahme | Absage sofort, ausbleibende Antwort nach Fristablauf: „Klärung erforderlich“. Koordination informieren; Ersatz manuell organisieren. |
|
||||
|
||||
## Ablauf in der Oberfläche
|
||||
|
||||
### 1. Alarm auslösen
|
||||
|
||||
Im veröffentlichten Notdienstplan erhält die Einteilung eine Aktion **Notdienstalarm auslösen**. Eine aktuelle Alarmübersicht ist zusätzlich über das Dashboard erreichbar.
|
||||
|
||||
Der Dialog zeigt Datum, eingeteilte Familie und deren Empfängeradressen. Die auslösende Person wählt die Einsatzdauer, prüft die Antwortfrist und bestätigt den Versand. Bei **Bestimmte Uhrzeiten** sind Beginn und Ende Pflicht; Ende liegt nach Beginn. Bei **Ganzer Kita-Tag** sind keine künstlichen Uhrzeitangaben nötig. Die gewählte Dauer erscheint in der E-Mail und auf der Rückmeldeseite.
|
||||
|
||||
Ein Alarm ist nur für heutige oder zukünftige Einteilungen eines veröffentlichten Plans der eigenen Kita möglich. Notdienst muss für die Kita aktiviert sein. Fehlende Elternkonten werden vor der Auslösung deutlich angezeigt. Eine aktive Alarmierung verhindert einen zweiten Alarm für dieselbe Einteilung.
|
||||
|
||||
Optional kann die ausgefallene Fachkraft intern zugeordnet werden. Dies dient der Zuordnung und erzeugt keine automatische Krankheitsbuchung im Personalbereich. Private interne Notizen und Namen ausgefallener Fachkräfte gehören nicht in die Eltern-E-Mail.
|
||||
|
||||
### 2. Familie informieren und Antwort erfassen
|
||||
|
||||
Jedes Elternkonto erhält eine eigene E-Mail mit Kita, Datum, Einsatzdauer, Antwortfrist und persönlichem Rückmeldelink. Die Nachricht fragt eindeutig nach einer Übernahme. Alle Elternkonten des Haushalts sehen in der App denselben aktiven Alarm.
|
||||
|
||||
Die Rückmeldeseite funktioniert wie bisher ohne Login über einen persönlichen Token. Das Öffnen des Links löst keine Zusage aus. Erst die ausdrückliche Auswahl von **Wir übernehmen** oder **Wir können nicht** mit Bestätigung schreibt die Antwort.
|
||||
|
||||
Die Antwort wird am gemeinsamen Haushaltseinsatz gespeichert, einschließlich Zeitpunkt und antwortendem Elternkonto. Alle anderen Links zeigen anschließend die gemeinsame Antwort. Gleichzeitige gegensätzliche Antworten dürfen nicht beide wirksam werden. Eine bereits gespeicherte Antwort lässt sich nicht versehentlich durch einen zweiten Klick überschreiben.
|
||||
|
||||
### 3. Stand verfolgen und Ersatz klären
|
||||
|
||||
Die Alarmübersicht zeigt Datum, Dauer, Familie, Antwortfrist, gemeinsamen Rückmeldestatus und den Versandstand je Empfänger. Offene Alarme für heute werden besonders hervorgehoben.
|
||||
|
||||
| Anzeige | Bedeutung und nächste Aktion |
|
||||
| --- | --- |
|
||||
| Antwort ausstehend | Familie wurde angefragt; die Antwortfrist läuft. |
|
||||
| Übernahme bestätigt | Der Haushalt hat rechtzeitig zugesagt. Koordination und Admins sehen die Zusage. |
|
||||
| Klärung erforderlich – Absage | Der Haushalt kann nicht übernehmen. Koordination organisiert Ersatz. |
|
||||
| Klärung erforderlich – Frist abgelaufen | Es liegt keine Antwort vor. Koordination fasst nach oder organisiert Ersatz. |
|
||||
| Storniert | Einsatz entfällt; Rückmeldelinks erlauben keine weitere Antwort. |
|
||||
|
||||
Versandfehler sind ein eigener Zustand und dürfen nicht als Absage der Familie erscheinen. Eine fehlgeschlagene E-Mail wird deutlich angezeigt und kann erneut versendet werden; andere Empfänger werden trotzdem bedient.
|
||||
|
||||
Bei Absage oder Fristablauf erhalten zuständige Koordinations- und Admin-Konten derselben Kita einen Hinweis in der App und eine E-Mail. Die Fristmeldung wird je Alarm einmal erzeugt. Die Koordination organisiert Ersatz in dieser Version manuell; weitere Familien werden nicht automatisch alarmiert.
|
||||
|
||||
## Vorgeschlagene Ausgestaltung für Sonderfälle
|
||||
|
||||
Diese Details ergänzen die Nutzerentscheidungen als Umsetzungsvorschlag:
|
||||
|
||||
- **Verspätete Zusage:** Nach Fristablauf bleibt eine Rückmeldung möglich, solange der Einsatz läuft und der Alarm nicht storniert ist. Eine verspätete Zusage wird als solche angezeigt und muss von der Koordination bestätigt werden, damit sie nicht mit bereits organisiertem Ersatz kollidiert.
|
||||
- **Telefonische Rückmeldung:** Admin oder Koordination kann eine Zusage bzw. Absage für den Haushalt dokumentieren. Herkunft, handelndes Konto und Zeitpunkt bleiben nachvollziehbar.
|
||||
- **Antwort ändern:** Eine bereits dokumentierte Antwort wird nur durch Admin oder Koordination korrigiert. Eltern erhalten auf der Rückmeldeseite einen Hinweis, die Kita bei Änderungen zu kontaktieren.
|
||||
- **Stornieren:** Admin oder Koordination storniert mit einer internen Begründung. Alle angeschriebenen Elternkonten werden informiert; offene Links werden unwirksam. Eine neue Alarmierung derselben Einteilung ist danach als neuer Vorgang möglich.
|
||||
- **Token-Ende:** Für einen ganzen Kita-Tag endet die Rückmeldemöglichkeit spätestens mit dem Datum in Europe/Berlin; bei bestimmten Uhrzeiten mit dem Einsatzende. Die 30-Minuten-Frist für Eskalation ist davon unabhängig.
|
||||
- **Planänderungen und Tausch:** Aktive Alarme schützen die zugehörige Einteilung vor einem unbemerkten Familienwechsel oder Diensttausch. Ersatz darf nicht durch Änderung eines noch aktiven Alarms auf eine andere Familie übertragen werden.
|
||||
- **Erinnerung und erneuter Versand:** Eine Wiederholung startet die Antwortfrist nicht automatisch neu. E-Mail-Annahme durch den Versanddienst, Rückmeldung und tatsächliche Anwesenheit bleiben getrennte Informationen.
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
Der bestehende `NotdienstAlert` speichert pro Elternkonto einen unabhängigen Status. Für die gemeinsame Haushaltsantwort wird ein übergeordneter Alarmvorgang benötigt; Empfänger, Tokens und Versandversuche sind untergeordnet. Bestehende Datensätze müssen bei der Schemaumstellung berücksichtigt werden, ohne widersprüchliche Einzelantworten als sichere Haushaltszusage zu übernehmen.
|
||||
|
||||
Am Vorgang werden Einteilung und Haushalt, auslösendes Konto, Zeitpunkt, Einsatzart, gegebenenfalls Uhrzeiten, Antwortfrist, gemeinsame Antwort, interne Zuordnung und Stornierungsdaten festgehalten. Empfängerdatensätze führen persönliche Tokens und deren Gültigkeit. Historie und Versandaufträge erlauben Wiederholung und nachvollziehbare Änderungen.
|
||||
|
||||
Alle Aktionen prüfen aktuelle Datenbankrolle, Kita-Zugehörigkeit und Zugriff serverseitig. Auslösung und gemeinsame Rückmeldung laufen transaktional. Eine Datenbanksicherung verhindert mehrere aktive Alarme für dieselbe Einteilung sowie widersprüchliche parallele Antworten.
|
||||
|
||||
Der Versand verwendet die vorhandene Resend-Anbindung mit persistenten Versandaufträgen. Elternmails enthalten ausschließlich notwendige Einsatzdaten. Interne Begründungen, Krankheitsdetails und Daten aus Personalzeitkonten werden nicht übernommen. Alarm-E-Mails sind unabhängig von der Abschalteinstellung für Personal-Entscheidungsmails.
|
||||
|
||||
Die Fristüberwachung braucht eine regelmäßig aufgerufene serverseitige Prüfung, auch wenn niemand die App geöffnet hat. Vorgesehen ist ein geschützter, einmal pro Minute aufgerufener Serverjob. Die Anzeige berechnet Fristablauf zusätzlich direkt aus der gespeicherten Antwortfrist. Die Benachrichtigung kann durch das Prüfintervall und den Mailtransport etwas nach Ablauf eintreffen. Für die Einführung muss der Job in der Hosting-Umgebung eingerichtet werden.
|
||||
|
||||
## Umsetzungsschritte
|
||||
|
||||
1. Gemeinsamen Alarmvorgang, Empfänger, Tokens, Verlauf und Schemaumstellung ergänzen.
|
||||
2. Auslösung, Haushaltsantwort, Korrektur, Stornierung und erneuten Versand absichern.
|
||||
3. E-Mail-Vorlagen, Versandaufträge und Benachrichtigung der Koordination anbinden.
|
||||
4. Alarmdialog, Rückmeldeseite, Dashboard-Hinweise und Statusübersicht umsetzen.
|
||||
5. Fristprüfung, Hosting-Konfiguration und Einführungsdokumentation ergänzen.
|
||||
6. Gemeinsame Haushaltsantworten, Fristen, Rollen, Versandfehler und Browserabläufe prüfen.
|
||||
|
||||
## Abnahmefälle
|
||||
|
||||
- Admin und Koordination dürfen auslösen; Eltern, Fachkräfte und Konten anderer Kitas dürfen es nicht.
|
||||
- Nur die im veröffentlichten Plan eingeteilte Familie wird zunächst angeschrieben.
|
||||
- Ganzer Kita-Tag und bestimmte Uhrzeiten erscheinen in E-Mail, App und Rückmeldeseite konsistent.
|
||||
- Eine Zusage oder Absage wirkt auf den gesamten Haushalt; weitere persönliche Links zeigen denselben Stand.
|
||||
- Zwei gleichzeitige Antworten, doppelte Auslösung und Fristprüfung erzeugen keine doppelten Vorgänge oder widersprüchlichen Zusagen.
|
||||
- Absage erzeugt sofort Klärungsbedarf; Fristablauf nach 30 Minuten erzeugt ihn auch bei geschlossener App.
|
||||
- Versandfehler verhindern keine Speicherung, unterbrechen nicht den Versand an weitere Empfänger und lassen sich gezielt wiederholen.
|
||||
- Verspätete Antwort, telefonisch dokumentierte Antwort und Stornierung bleiben nachvollziehbar.
|
||||
- Stornierte und zeitlich abgelaufene Links können keinen aktiven Einsatz zusagen.
|
||||
- Aktive Alarme schützen Einteilungen vor unbemerktem Tausch oder Familienwechsel.
|
||||
|
||||
## Vorhandene Grundlage
|
||||
|
||||
- `src/app/dashboard/actions.ts`: Auslösung und E-Mail-Versand, bisher ein separater Alarm pro Elternkonto.
|
||||
- `src/app/dashboard/alert-button.tsx`: Vorbereiteter, noch nicht eingebundener Auslösebutton.
|
||||
- `src/app/alert/[token]/`: Rückmeldeseite und Bestätigung, bisher nur Zusage und ohne zeitliche Token-Gültigkeit.
|
||||
- `src/app/dashboard/kalender/actions.ts`: Bestehender Tauschausschluss für aktive Alarmierungen.
|
||||
- `src/lib/mail.ts` und `src/lib/staff/email.ts`: Mailtransport sowie Vorlage für persistente, wiederholbare Versandaufträge.
|
||||
|
||||
Die Planung löst keine Alarme aus und versendet keine E-Mails.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Personalbereich: Arbeitszeit, Urlaub und Zeitkonto
|
||||
|
||||
Stand: 09.10.2026
|
||||
|
||||
## Einstieg und Zuständigkeiten
|
||||
|
||||
Der Bereich liegt unter `/dashboard/personal`. Kita-Admins (einschließlich Vorstand mit Rolle `ADMIN`) verwalten alle Personalprofile ihrer Kita. ErzieherInnen bearbeiten eigene Zeiten und Anträge. Eltern und Koordination haben keinen Zugriff. Der interne Teamkalender ist nur für Admins und ErzieherInnen zugänglich.
|
||||
|
||||
Neue Fachkräfte werden unter **ErzieherInnen → ErzieherIn anlegen** in einem Formular eingerichtet:
|
||||
|
||||
1. Vorname, Nachname und E-Mail-Adresse (Pflichtfelder).
|
||||
2. Tatsächlicher Beschäftigungsbeginn und separat der Start der Zeiterfassung (vorbelegt mit heute, änderbar). Die Erfassung darf nicht vor der Beschäftigung beginnen.
|
||||
3. **Fachkraft anlegen und einladen** speichert Stammdaten, Personalprofil, Accountverknüpfung und Mailauftrag gemeinsam in einer Transaktion. Unpassende oder schon verknüpfte E-Mail-Adressen verhindern die gesamte Anlage.
|
||||
4. Anschließend öffnet sich das Personalprofil. Dort werden datierte Netto-Sollstunden für Montag bis Sonntag, Jahresurlaub und ausdrücklich festgelegter Übertrag sowie vorhandene Überstunden eingerichtet. Der Anfangssaldo startet mit 0 Minuten und muss bei vorhandenen Überstunden vor der ersten Zeiterfassung angepasst werden.
|
||||
5. Die örtlichen Feiertage werden unter **Teamkalender** hinterlegt. Sie gelten separat von den Schließtagen des allgemeinen Kalenders.
|
||||
|
||||
Vorhandene Fachkräfte können ihre fehlende E-Mail und gegebenenfalls die beiden Startdaten direkt über **Bearbeiten** erhalten. Bereits eingerichtete Beschäftigungsdaten, Wochenmodelle und Konten bleiben dabei erhalten. Die E-Mail eines verknüpften Accounts wird angezeigt und kann im Stammdatendialog nicht geändert werden; Namensänderungen werden auch im verknüpften Fachkraft-Account übernommen.
|
||||
|
||||
Die Teamübersicht zeigt E-Mail und Zugangszustand: **Zugang fehlt**, **Einladung ausstehend**, **Account aktiviert** oder **Mailversand fehlgeschlagen**. Für offene Einladungen steht **Einladung erneut senden** zur Verfügung. Ein neuer Link ersetzt den alten. Ein Mailfehler entfernt keine Fachkraft: Er bleibt sichtbar, der Versand kann erneut angestoßen werden. Im Personalbereich kann außerdem der bestehende Mailauftrag erneut versucht werden.
|
||||
|
||||
Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Neue Accounts erhalten einen persönlichen, einmal verwendbaren Aktivierungslink mit sieben Tagen Gültigkeit per E-Mail; im Personalprofil kann der Admin zusätzlich einen Link zum Kopieren erzeugen. Ein vorhandener verifizierter Fachkraft-Account derselben Kita ohne andere Profilverknüpfung wird verbunden, ohne eine Aktivierungsmail zu verschicken. Accounts anderer Kitas oder Rollen werden nicht übernommen.
|
||||
|
||||
Die gemeinsame Anlage benötigt keine zusätzlichen Datenbankfelder oder Umgebungsvariablen. Sie verwendet die bestehende Resend-Anbindung; siehe [Deployment und Mailversand](../deploy/DEPLOYMENT_UND_MAILVERSAND.md).
|
||||
|
||||
## Arbeitszeit und Überstunden
|
||||
|
||||
Pro Tag sind mehrere Arbeitsblöcke mit Beginn, Ende und Pause möglich. Zeitblöcke dürfen nicht überlappen. Die Pause wird in Minuten vom Block abgezogen. Zeiten werden für heutige oder vergangene Tage innerhalb der Beschäftigung und ab Erfassungsbeginn erfasst. Blöcke über Mitternacht werden auf zwei Tage aufgeteilt.
|
||||
|
||||
Die Tagesbilanz ist **Istzeit + Urlaub/Krankheit − Sollzeit**. Arbeit an einem freien Tag oder Feiertag zählt als zusätzliche Istzeit. Die Wochenansicht zeigt alle sieben Tage; in einer laufenden Woche fehlen zukünftige Arbeitszeiten noch, deshalb ist ihre Differenz vorläufig.
|
||||
|
||||
Die Fachkraft reicht ihre Woche nach dem letzten vorgesehenen Arbeitstag ein. Offene Abwesenheiten im Zeitraum müssen zuerst geklärt sein. Der Admin prüft die einzelnen Zeiten, genehmigt die Woche oder gibt sie mit Rückmeldung zurück. Eingereichte und genehmigte Wochen sperren Zeitänderungen. Eigene Wochen und eigene Anträge können nicht selbst freigegeben werden.
|
||||
|
||||
Beim Genehmigen wird die Tagesberechnung gespeichert. Nur genehmigte Wochen, der Anfangssaldo und wirksame manuelle Korrekturen zählen zum bestätigten Zeitkonto. Offene oder eingereichte Wochen wirken noch nicht auf dieses Konto. Eine Rückgabe entfernt die Wochenbuchung bis zur erneuten Freigabe. Fehler in manuellen Korrekturen werden durch eine begründete Gegenbuchung behoben.
|
||||
|
||||
## Feste Wochentage und seltene Wechsel
|
||||
|
||||
Wochenmodelle gelten ab ihrem Datum. Eine spätere Änderung überschreibt kein früheres Modell. Seltene Tausche werden über zwei Tagesausnahmen erfasst: der bisherige Arbeitstag erhält 0 Sollstunden, der Ersatztermin die vorgesehenen Stunden.
|
||||
|
||||
Genehmigte Abwesenheiten sowie eingereichte oder genehmigte Wochen schützen ihren Zeitraum auch vor Änderungen an Modellen, Ausnahmen und Feiertagen. Betroffene Freigaben müssen vor einer solchen Änderung zurückgegeben bzw. storniert werden. Grunddaten und Anfangssaldo werden nach der ersten Erfassung geschützt; das Beschäftigungsende bleibt über einen eigenen Ablauf verwaltbar, sofern bestehende Zeiten und Anträge nicht verletzt werden.
|
||||
|
||||
## Urlaub
|
||||
|
||||
Urlaub ist ganztägig oder für einen einzelnen halben Tag möglich. Nur vorgesehene Arbeitstage verbrauchen Urlaub. Wochenenden sind nicht pauschal frei: Entscheidend sind Wochenmodell und Tagesausnahmen. Feiertage verbrauchen keinen Urlaub und erzeugen keine Sollzeit. Ein halber Urlaubstag schreibt die Hälfte der Tages-Sollzeit gut; bei ungerader Minutenzahl wird auf eine ganze Minute gerundet.
|
||||
|
||||
Ein Jahreskonto führt Anspruch plus Übertrag, genommenen Urlaub, genehmigten geplanten Urlaub, offene Anträge und verfügbaren Rest. Offene Anträge werden bei weiteren Anträgen berücksichtigt. Abgelehnte und stornierte Anträge verbrauchen nichts. Zeiträume über den Jahreswechsel werden auf die jeweiligen Jahreskonten verteilt. Anspruch und Übertrag werden nicht automatisch anteilig berechnet oder zum Jahreswechsel übertragen.
|
||||
|
||||
Offene Anträge können geändert oder zurückgezogen werden. Bei genehmigten Anträgen wird eine Stornierung beantragt. Bis zur Entscheidung bleibt die Abwesenheit gebucht. Krankheit hinterlegt der Admin; sie entlastet die Sollzeit, ohne Urlaub zu verbrauchen. KollegInnen sehen Krankheit im Teamkalender nur als **Abwesend**, ohne persönliche Notizen oder Kontodaten.
|
||||
|
||||
## Freizeitausgleich
|
||||
|
||||
Freizeitausgleich wird für einen einzelnen vorgesehenen Arbeitstag in Stunden beantragt. Das bestätigte verfügbare Guthaben muss ausreichen; weitere offene Anträge werden dabei berücksichtigt.
|
||||
|
||||
Nach Genehmigung wird die Zeit reserviert. Freizeitausgleich erzeugt keine zusätzliche Istzeit oder Urlaubsgutschrift. Beispiel: 6 Stunden Soll, 5 Stunden Arbeit und 1 Stunde Freizeitausgleich ergeben eine Tagesbilanz von −1 Stunde. Mit der Wochenfreigabe wird diese Stunde im Zeitkonto gebucht und die Reservierung entfällt. Dadurch wird die Stunde genau einmal abgezogen. Bei offenen Stornierungen bleibt die Reservierung erhalten.
|
||||
|
||||
## Hinweise und Nachvollziehbarkeit
|
||||
|
||||
Neue Anträge und eingereichte Wochen erzeugen interne Hinweise für Kita-Admins. Entscheidungen und Rückgaben erzeugen Hinweise für die betroffene Fachkraft. Hinweise lassen sich als gelesen markieren; jede Person kann ausschließlich eigene Hinweise quittieren. Das Personalprofil zeigt die letzten 30 Änderungen. Im Datenmodell werden sämtliche Änderungen mit Zeitpunkt und handelnder User-ID gespeichert.
|
||||
|
||||
Parallele Änderungen laufen in serialisierbaren Datenbanktransaktionen mit Wiederholung bei Schreibkonflikten. Rollen, Accountstatus, Eigentümer und Kita-Zugehörigkeit werden serverseitig geprüft. Das Löschen von Fachkräften mit Personalprofil wird verhindert; sie können auf inaktiv gesetzt werden. Bei Accountlöschung bleiben Personalaufzeichnungen ohne Accountverknüpfung erhalten. Die Löschung einer Kita entfernt auch ihren Personalbereich.
|
||||
|
||||
## Einführung und Schema
|
||||
|
||||
Das Projekt verwendet weiterhin seinen vorhandenen Ablauf mit `prisma db push`. Es gibt keine vollständige versionierte Prisma-Migrationshistorie. Die neuen Tabellen sind additiv; vorhandene Personen erhalten noch kein automatisch befülltes Beschäftigungsprofil.
|
||||
|
||||
- Lokal: Abhängigkeiten installieren, Prisma-Client generieren, Schema gegen eine dafür vorgesehene Datenbank anwenden, App starten und Profile einrichten.
|
||||
- Im vorhandenen Docker-Ablauf erzeugt `npm run build:prod` den Client; `scripts/start-production.sh` wendet das Schema vor App-Start an.
|
||||
- [Prüfbares SQL](../deploy/sql/personalbereich.sql) beschreibt dieselbe additive Änderung gegen den dokumentierten Ausgangsstand. Es ist einmalig und nicht idempotent. Entweder den vorhandenen Schemaabgleich **oder** dieses SQL verwenden; das SQL nicht nach bereits erfolgtem Schemaabgleich erneut ausführen.
|
||||
- Ist der Personalbereich bereits auf Stand `043fed2` eingeführt, beschreibt [Personalbereich-E-Mail-SQL](../deploy/sql/personalbereich-email.sql) ausschließlich die zusätzlichen Mail-Tabellen und die Profileinstellung. Auch dieses Skript ist eine einmalige Alternative zum Schemaabgleich.
|
||||
|
||||
Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht verändert. Alle DB- und Browserprüfungen liefen mit synthetischen Daten in einer separaten lokalen PostgreSQL-Datenbank.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
- `npm run test:staff`: Berechnungstests für Teilzeit, halbe Tage, Feiertage, Jahreswechsel, Statuswechsel, Pausen, Snapshots und Freizeitausgleich.
|
||||
- `npm run test:staff:integration`: echte Datenbankabläufe für Mandantentrennung, Rollen und Eigentümer, Anträge, Freigaben, parallele Änderungen, Sperren, Rückgaben, Aktivierungslinks und Beschäftigungsende.
|
||||
- Integrationstests akzeptieren ausschließlich `127.0.0.1:55439/kita_staff_test`. Beispiel: `DATABASE_URL=postgresql://USER@127.0.0.1:55439/kita_staff_test npm run test:staff:integration`. Die leere Testdatenbank vorher mit dem aktuellen Schema einrichten. Tests bereinigen nur ihre eigens erzeugten Testkitas.
|
||||
- Browserprüfung: Admin richtet Profil, 18-Stunden-Woche und 18 Urlaubstage ein; Erzieherin erfasst drei Blöcke mit Pausen, reicht eine 19-Stunden-Woche ein und beantragt einen Urlaubstag. Nach Adminfreigabe zeigt das Zeitkonto 11 Stunden (10 Stunden Anfangssaldo + 1 Stunde Wochenplus) und das Urlaubskonto 17 Tage. Der Urlaub erscheint im Teamkalender.
|
||||
|
||||
Ergebnisse der abschließenden Prüfung:
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Berechnungstests | 9 bestanden. |
|
||||
| Integrationstests | 25 bestanden; einschließlich gemeinsamer Anlage, vollständigem Rollback bei E-Mail-Konflikten, Bearbeitung bestehender Fachkräfte, Kontenschutz, Rollenprüfung und Empfänger, Opt-out, Versandfehler, Token-Erneuerung und paralleler Versandversuche. Testkitas danach bereinigt. |
|
||||
| TypeScript | Erfolgreich. |
|
||||
| ESLint für sämtliche geänderten Anwendungsteile und neue Tests | Erfolgreich, ohne Warnungen. |
|
||||
| Projektweites ESLint | Die bereits vorhandenen zwei `as any`-Fehler in `src/lib/auth-utils.ts` und neun Warnungen bestehen weiterhin. |
|
||||
| Endgültiger Produktionsbuild | Erfolgreich, einschließlich `/dashboard/personal`. |
|
||||
| Vollständiges und inkrementelles SQL-Skript | Erfolgreich in isolierten Prüfschemata; anschließend jeweils kein Unterschied zum Prisma-Schema. |
|
||||
| Browser | Admin- und ErzieherInnen-Ablauf einschließlich Freigaben erfolgreich. Mobile Ansicht und Arbeitszeitdialog ohne Seitenüberlauf geprüft. Abschalten der E-Mail-Hinweise im Demo-Profil gespeichert und nach Neuladen verifiziert; anschließend wieder eingeschaltet. |
|
||||
|
||||
Die vom bestehenden Build gemeldete Warnung zu mehreren Lockfiles bzw. dem Turbopack-Arbeitsverzeichnis und die bestehende Prisma-Konfigurationswarnung sind unabhängig vom Personalbereich. Eine produktive Bereitstellung oder Mailzustellung wurde nicht durchgeführt.
|
||||
|
||||
## Prüfung der gemeinsamen Fachkraft-Anlage (09.10.2026)
|
||||
|
||||
- 25 Personal-Integrationstests, TypeScript, ESLint der geänderten Anwendungsteile und Produktionsbuild erfolgreich.
|
||||
- Lokale Browserprüfung mit synthetischen Daten: Datum vor Beschäftigungsbeginn abgewiesen, Eingaben erhalten, korrigierte Anlage erfolgreich und unmittelbar zum Personalprofil weitergeleitet.
|
||||
- Resend im lokalen Produktionsbuild bewusst deaktiviert: gespeicherte Fachkraft und wiederholbarer Mailfehler in Personalbereich und Teamliste sichtbar; erneute Einladung aus der Teamliste erfolgreich ausgelöst.
|
||||
- Bestehende Fachkraft ohne Zugang zeigt E-Mail und fehlende Startdaten direkt im Bearbeitungsdialog. Mobilprüfung bei 390 × 844 Pixeln: Dialog ohne horizontalen Überlauf, längerer Inhalt innerhalb des Dialogs scrollbar.
|
||||
- Keine echten Aktivierungsmails versendet; keine Änderung an Produktionsdaten vorgenommen.
|
||||
|
||||
## E-Mail-Hinweise
|
||||
|
||||
Die E-Mail-Erweiterung wurde am 08.10.2026 ausdrücklich beauftragt. Sie verwendet die vorhandene Resend-Anbindung:
|
||||
|
||||
| Nachricht | Empfänger | Inhalt |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Accountaktivierung | Vom Kita-Admin eingetragene E-Mail-Adresse der Fachkraft | Kita, Name, persönlicher Aktivierungslink mit sieben Tagen Gültigkeit. |
|
||||
| Antrag / Änderung / Stornierung oder Arbeitswoche eingereicht | Alle Kita-Admins derselben Kita, einzeln angeschrieben | Name, Zeitraum und Hinweis auf Prüfung im geschützten Personalbereich. |
|
||||
| Entscheidung / Rückgabe | Account-Adresse der betroffenen Fachkraft | Art, Zeitraum und Status mit Link in den geschützten Personalbereich. |
|
||||
|
||||
Fachkräfte können Entscheidungsmails unter **Mein Profil → E-Mail-Hinweise** abschalten. Aktivierungslinks und interne App-Hinweise bleiben davon unabhängig. Krankheitsbuchungen erzeugen keine E-Mail. Private Notizen, Entscheidungsbegründungen und Zeit- oder Urlaubskontostände werden nicht per E-Mail übertragen.
|
||||
|
||||
Versandaufträge werden innerhalb derselben Datenbanktransaktion wie die Personalaktion gespeichert. Der Versand erfolgt anschließend; ein Fehler macht den gespeicherten Antrag nicht rückgängig. Ausstehende Mails erscheinen im Personalbereich mit **Versand erneut versuchen**. Admins dürfen die Aufträge ihrer Kita wiederholen, Fachkräfte nur eigene. Pro Aktion werden bis zu 50 ausstehende Aufträge verarbeitet. Es gibt keinen zusätzlichen Hintergrundjob.
|
||||
|
||||
Vor jedem Versand werden Kita-Zugehörigkeit, aktuelle Rolle, Accountverknüpfung und gegebenenfalls die Abschalteinstellung geprüft. Ersetzte, abgelaufene oder bereits verwendete Aktivierungslinks werden übersprungen. Eine fünfminütige Versandsperre verhindert parallele Versuche für denselben Auftrag; ein stabiler Resend-Idempotenzschlüssel vermindert doppelte Zustellungen bei Wiederholungen. Eine Zustellbestätigung des Empfängerpostfachs wird nicht erfasst.
|
||||
|
||||
Benötigt werden `RESEND_API_KEY`, `EMAIL_FROM` und eine öffentliche App-Adresse über `NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL` oder `NEXTAUTH_URL`. Ohne Konfiguration bleibt ein Produktionsversand ausstehend und wird als Warnung angezeigt. Im Entwicklungsmodus ohne Resend-Schlüssel wird der bestehende lokale Mailtransport simuliert. Die Tests verwenden ausschließlich einen eingesetzten Testtransport; es wurden keine echten E-Mails verschickt.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Elternabläufe: Mitteilungen und Teilnahmeantworten
|
||||
|
||||
## Verhalten
|
||||
|
||||
- Die Übersicht zeigt offene Rückmeldungen und drei aktuelle Mitteilungen. Das Archiv unter `/dashboard/mitteilungen` enthält alle für das eigene Konto sichtbaren Meldungen, zwölf pro Seite.
|
||||
- Termine können eine Antwort pro Kind oder pro Familie verlangen. Familienzusagen brauchen mindestens einen teilnehmenden Erwachsenen; Absagen speichern 0 Erwachsene. Beide Elternkonten bearbeiten dieselbe Antwort.
|
||||
- Die Terminart schlägt bei neuen Terminen eine Antwortart vor. Die Verwaltung kann sie ändern, solange keine Antworten vorliegen. Danach verhindert auch die Server Action einen Wechsel. Normale Terminänderungen erhalten die bisherigen Teilnahmefelder.
|
||||
- Kinderantworten werden für alle eigenen aktiven Kinder gemeinsam in einer Transaktion gespeichert. Eigene und fremde Kinder dürfen nicht gemischt werden. Frist, Terminstatus, Haushalt, Kita und aktiviertes Terminmodul werden serverseitig geprüft.
|
||||
- Ohne eigene Rückmeldefrist gilt der Terminbeginn. Nach Fristablauf bleiben die Antworten im Kalender lesbar. Die Eingabe ist in Monatsansicht, Liste und Agenda erreichbar.
|
||||
- Empfängergruppen sind Eltern, ErzieherInnen oder beide. Die Gruppe steuert App-Anzeige, Lesestatus-Aktion, Anhänge und optionalen E-Mail-Versand. Admins können sämtliche Meldungen der eigenen Kita zur Verwaltung sehen. Eltern mit Koordinationsrolle gehören mit ihrem Haushalt zur Elterngruppe; Erzieherkonten gehören zur Erziehergruppe.
|
||||
- Teilnehmerhinweise werden nur bei einer Zusage des Haushalts oder für Admin/Koordination an den Kalender-Client übertragen.
|
||||
|
||||
## Bestehende Daten und Schemaaktualisierung
|
||||
|
||||
Das Projekt verwendet weiterhin `prisma db push`; es besitzt keine Prisma-Migrationshistorie. Der vorhandene Produktionsstart aktualisiert das Schema automatisch. Die neuen Pflichtfelder haben kompatible Defaults: bisherige Termine bleiben `CHILD`, bisherige Meldungen erhalten `ALLE`. Bestehende Kinderantworten werden nicht verändert.
|
||||
|
||||
Für eine kontrollierte, einmalige Aktualisierung einer Datenbank mit dem bisherigen Schema liegt zusätzlich die additive SQL-Datei `prisma/updates/20261007_parent_flows.sql` vor. Sie ist eine Alternative zum ersten `db push`, kein nachträglich anzuwendender zweiter Schritt. Beispiel:
|
||||
|
||||
```sh
|
||||
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction -f prisma/updates/20261007_parent_flows.sql
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
Die Datei gehört bewusst nicht in `prisma/migrations`: Eine Einführung von `prisma migrate deploy` würde zuerst eine Baseline für bereits existierende Datenbanken verlangen.
|
||||
|
||||
## Verifikation
|
||||
|
||||
```sh
|
||||
npm run test:parent-flows
|
||||
TEST_DATABASE_URL=postgresql://USER@127.0.0.1:PORT/TESTDB npm run test:parent-flows:integration
|
||||
npx tsc --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die Integrationstests benötigen eine Node.js-Version mit experimentellem Modul-Mocking (hier geprüft mit Node.js 26), eine ausdrücklich über `TEST_DATABASE_URL` gesetzte lokale PostgreSQL-Testdatenbank und das aktuelle Schema. Sie legen eigene Kitas mit zufälligen IDs an und entfernen ausschließlich diese Testkitas wieder. Kein automatischer Zugriff auf `DATABASE_URL` ohne Testfreigabe.
|
||||
|
||||
Die Tests prüfen Erwachsenenanzahl, gemischte Kinderantworten, gemeinsame Familienantworten, Fristablauf, Terminabsage, Moduldeaktivierung, Kita-/Haushaltsgrenzen, gesperrte Antwortart einschließlich paralleler erster Rückmeldung, erhaltene Terminoptionen sowie verweigerte Team-Meldungen und Anhänge für Eltern. E-Mail-Empfänger werden anhand derselben Gruppenregeln geprüft; es werden keine echten E-Mails verschickt.
|
||||
|
||||
Zusätzlich im Browser geprüft: Familienzusage, unterschiedliche Antworten für zwei Kinder, verschwundene offene Hinweise, Lesen/Ändern der gemeinsamen Antwort aus einem zweiten Elternkonto, abgelaufene Eingabe, Kalender-Monats- und Agendaansicht, Lesestatus, Archivnavigation, Verwaltungsauswertung, gesperrte Antwortart und Eltern-/Erzieher-Empfängertrennung. Die Rückmeldeformulare und Übersicht wurden auch bei 390 Pixeln Breite visuell geprüft.
|
||||
+7
-2
@@ -7,7 +7,7 @@
|
||||
"build": "next build && node scripts/prepare-standalone.mjs",
|
||||
"build:prod": "prisma generate && next build && node scripts/prepare-standalone.mjs",
|
||||
"start": "next start",
|
||||
"start:prod": "node .next/standalone/server.js",
|
||||
"start:prod": "sh .next/standalone/scripts/start-production.sh",
|
||||
"lint": "eslint",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
@@ -19,7 +19,12 @@
|
||||
"db:provision-runtime": "node scripts/provision-runtime-role.mjs",
|
||||
"uploads:backfill": "tsx scripts/backfill-attachment-sizes.ts",
|
||||
"uploads:cleanup": "tsx scripts/cleanup-attachments.ts",
|
||||
"test:security:http": "node tests/security-http.cjs"
|
||||
"test:security:http": "node tests/security-http.cjs",
|
||||
"test:staff": "tsx --test tests/staff-calculations.test.ts",
|
||||
"test:staff:integration": "tsx --test tests/staff-service.test.ts",
|
||||
"test:alarms": "tsx --experimental-test-module-mocks --test tests/notdienst-alarm.test.ts",
|
||||
"test:parent-flows": "tsx --test tests/parent-flows.test.ts",
|
||||
"test:parent-flows:integration": "tsx --experimental-test-module-mocks --test tests/parent-flows.integration.test.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
BEGIN;
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnnouncementAudience" AS ENUM ('ELTERN', 'ERZIEHER', 'ALLE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotdienstAlarmStatus" AS ENUM ('PENDING', 'CONFIRMED', 'DECLINED', 'LATE_CONFIRMATION', 'REVIEW_REQUIRED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCEL_REQUESTED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffLeaveKind" AS ENUM ('VACATION', 'TIME_OFF', 'SICKNESS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffWeekStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'APPROVED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "announcements" ADD COLUMN "audience" "AnnouncementAudience" NOT NULL DEFAULT 'ALLE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "termine" ADD COLUMN "rsvpMode" "RsvpMode" NOT NULL DEFAULT 'CHILD';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "notdienst_alerts" ADD COLUMN "alarmId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"educatorId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"employmentStart" DATE NOT NULL,
|
||||
"employmentEnd" DATE,
|
||||
"trackingStart" DATE NOT NULL,
|
||||
"openingMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"inviteToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_work_patterns" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"effectiveFrom" DATE NOT NULL,
|
||||
"dailyMinutes" INTEGER[],
|
||||
|
||||
CONSTRAINT "staff_work_patterns_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_day_overrides" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_day_overrides_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_holidays" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_holidays_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leave_accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"entitlementUnits" INTEGER NOT NULL,
|
||||
"carryUnits" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "staff_leave_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leaves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"kind" "StaffLeaveKind" NOT NULL,
|
||||
"status" "StaffRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE NOT NULL,
|
||||
"halfDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"minutes" INTEGER,
|
||||
"note" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"requestedById" TEXT NOT NULL,
|
||||
"reviewedById" TEXT,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_leaves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_leave_days" (
|
||||
"id" TEXT NOT NULL,
|
||||
"leaveId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"vacationUnits" INTEGER NOT NULL DEFAULT 0,
|
||||
"creditMinutes" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_leave_days_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_time_entries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"startMinute" INTEGER NOT NULL,
|
||||
"endMinute" INTEGER NOT NULL,
|
||||
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"note" TEXT,
|
||||
|
||||
CONSTRAINT "staff_time_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_weeks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"weekStart" DATE NOT NULL,
|
||||
"status" "StaffWeekStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"submittedAt" TIMESTAMP(3),
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"reviewedById" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"dailySnapshot" JSONB,
|
||||
|
||||
CONSTRAINT "staff_weeks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_adjustments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_adjustments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "staff_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "family_event_participations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"status" "EventParticipationStatus" NOT NULL,
|
||||
"adultCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "family_event_participations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarms" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"assignmentId" TEXT,
|
||||
"activeAssignmentId" TEXT,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"familyName" TEXT NOT NULL,
|
||||
"childName" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"wholeDay" BOOLEAN NOT NULL DEFAULT true,
|
||||
"startMinute" INTEGER,
|
||||
"endMinute" INTEGER,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"deadline" TIMESTAMP(3) NOT NULL,
|
||||
"status" "NotdienstAlarmStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"triggeredById" TEXT,
|
||||
"triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"respondedById" TEXT,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"responseSource" TEXT,
|
||||
"escalatedAt" TIMESTAMP(3),
|
||||
"educatorId" TEXT,
|
||||
"notes" TEXT,
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"cancelReason" TEXT,
|
||||
|
||||
CONSTRAINT "notdienst_alarms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_recipients" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"sessionVersion" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_recipients_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventKey" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"readAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "notdienst_alarm_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventKey" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notdienst_alarm_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"alarmId" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notdienst_alarm_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_profiles_educatorId_key" ON "staff_profiles"("educatorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_profiles_userId_key" ON "staff_profiles"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_profiles_kitaId_idx" ON "staff_profiles"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_work_patterns_profileId_effectiveFrom_key" ON "staff_work_patterns"("profileId", "effectiveFrom");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_day_overrides_profileId_date_key" ON "staff_day_overrides"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_holidays_kitaId_date_key" ON "staff_holidays"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_leave_accounts_profileId_year_key" ON "staff_leave_accounts"("profileId", "year");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_leaves_profileId_status_startDate_idx" ON "staff_leaves"("profileId", "status", "startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_leave_days_leaveId_date_key" ON "staff_leave_days"("leaveId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_time_entries_profileId_date_idx" ON "staff_time_entries"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_weeks_profileId_weekStart_key" ON "staff_weeks"("profileId", "weekStart");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_adjustments_profileId_date_idx" ON "staff_adjustments"("profileId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_history_profileId_createdAt_idx" ON "staff_history"("profileId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_notifications_userId_readAt_createdAt_idx" ON "staff_notifications"("userId", "readAt", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "family_event_participations_kitaId_idx" ON "family_event_participations"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "family_event_participations_familyId_idx" ON "family_event_participations"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "family_event_participations_eventId_familyId_key" ON "family_event_participations"("eventId", "familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarms_activeAssignmentId_key" ON "notdienst_alarms"("activeAssignmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarms_kitaId_date_idx" ON "notdienst_alarms"("kitaId", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarms_status_deadline_idx" ON "notdienst_alarms"("status", "deadline");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_recipients_token_key" ON "notdienst_alarm_recipients"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_recipients_alarmId_userId_key" ON "notdienst_alarm_recipients"("alarmId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_notifications_userId_readAt_idx" ON "notdienst_alarm_notifications"("userId", "readAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_notifications_alarmId_userId_eventKey_key" ON "notdienst_alarm_notifications"("alarmId", "userId", "eventKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_emails_sentAt_skippedAt_createdAt_idx" ON "notdienst_alarm_emails"("sentAt", "skippedAt", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notdienst_alarm_emails_alarmId_userId_eventKey_key" ON "notdienst_alarm_emails"("alarmId", "userId", "eventKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notdienst_alarm_history_alarmId_createdAt_idx" ON "notdienst_alarm_history"("alarmId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_work_patterns" ADD CONSTRAINT "staff_work_patterns_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_day_overrides" ADD CONSTRAINT "staff_day_overrides_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_holidays" ADD CONSTRAINT "staff_holidays_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leave_accounts" ADD CONSTRAINT "staff_leave_accounts_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leaves" ADD CONSTRAINT "staff_leaves_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_leave_days" ADD CONSTRAINT "staff_leave_days_leaveId_fkey" FOREIGN KEY ("leaveId") REFERENCES "staff_leaves"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_time_entries" ADD CONSTRAINT "staff_time_entries_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_weeks" ADD CONSTRAINT "staff_weeks_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_adjustments" ADD CONSTRAINT "staff_adjustments_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_history" ADD CONSTRAINT "staff_history_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_notifications" ADD CONSTRAINT "staff_notifications_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_recipients" ADD CONSTRAINT "notdienst_alarm_recipients_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_notifications" ADD CONSTRAINT "notdienst_alarm_notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_emails" ADD CONSTRAINT "notdienst_alarm_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alarm_history" ADD CONSTRAINT "notdienst_alarm_history_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_alarmId_fkey" FOREIGN KEY ("alarmId") REFERENCES "notdienst_alarms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,13 @@
|
||||
BEGIN;
|
||||
-- Additional tenant invariants intentionally supplement Prisma relations.
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_tenant_fkey" FOREIGN KEY ("educatorId", "kitaId") REFERENCES "educators"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_eventId_tenant_fkey" FOREIGN KEY ("eventId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_assignmentId_tenant_fkey" FOREIGN KEY ("assignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_activeAssignmentId_tenant_fkey" FOREIGN KEY ("activeAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_triggeredById_tenant_fkey" FOREIGN KEY ("triggeredById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_respondedById_tenant_fkey" FOREIGN KEY ("respondedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
ALTER TABLE "notdienst_alarms" ADD CONSTRAINT "notdienst_alarms_educatorId_tenant_fkey" FOREIGN KEY ("educatorId", "kitaId") REFERENCES "educators"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
COMMIT;
|
||||
+418
-29
@@ -63,6 +63,17 @@ enum TerminStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum RsvpMode {
|
||||
CHILD
|
||||
FAMILY
|
||||
}
|
||||
|
||||
enum AnnouncementAudience {
|
||||
ELTERN
|
||||
ERZIEHER
|
||||
ALLE
|
||||
}
|
||||
|
||||
enum EventParticipationStatus {
|
||||
ATTENDING
|
||||
NOT_ATTENDING
|
||||
@@ -84,6 +95,15 @@ enum NotdienstAlertStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum NotdienstAlarmStatus {
|
||||
PENDING
|
||||
CONFIRMED
|
||||
DECLINED
|
||||
LATE_CONFIRMATION
|
||||
REVIEW_REQUIRED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum NotdienstSwapStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
@@ -143,7 +163,11 @@ model Kita {
|
||||
notdienstAvailabilities NotdienstAvailability[]
|
||||
notdienstAssignments NotdienstAssignment[]
|
||||
notdienstAlerts NotdienstAlert[]
|
||||
notdienstAlarms NotdienstAlarm[]
|
||||
notdienstSwapRequests NotdienstSwapRequest[]
|
||||
staffProfiles StaffProfile[]
|
||||
staffHolidays StaffHoliday[]
|
||||
familyEventParticipations FamilyEventParticipation[]
|
||||
|
||||
@@map("kitas")
|
||||
}
|
||||
@@ -158,15 +182,17 @@ model Family {
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
name String
|
||||
notdienstAlarms NotdienstAlarm[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
children Child[]
|
||||
dutyAssignments DutyAssignment[]
|
||||
users User[]
|
||||
children Child[]
|
||||
dutyAssignments DutyAssignment[]
|
||||
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
|
||||
familyEventParticipations FamilyEventParticipation[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("families")
|
||||
@@ -212,6 +238,7 @@ model User {
|
||||
privacyPolicyVersion String?
|
||||
/// Zeitstempel des Opt-Ins für das interne Kita-Adressbuch (Modul 3).
|
||||
directoryOptInAt DateTime?
|
||||
staffDecisionEmails Boolean @default(true)
|
||||
|
||||
// Auth-Bookkeeping
|
||||
emailVerifiedAt DateTime?
|
||||
@@ -227,21 +254,27 @@ 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[]
|
||||
staffEmails StaffEmail[]
|
||||
alarmRecipients NotdienstAlarmRecipient[]
|
||||
alarmNotifications NotdienstAlarmNotification[]
|
||||
alarmEmails NotdienstAlarmEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@ -300,12 +333,238 @@ model Educator {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
notdienstAlerts NotdienstAlert[]
|
||||
staffProfile StaffProfile?
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("educators")
|
||||
@@unique([id, kitaId])
|
||||
}
|
||||
|
||||
// Personalbereich: Tage als UTC-Datum, Zeiten als Minuten, Anspruch in halben Tagen.
|
||||
enum StaffRequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCEL_REQUESTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum StaffLeaveKind {
|
||||
VACATION
|
||||
TIME_OFF
|
||||
SICKNESS
|
||||
}
|
||||
|
||||
enum StaffWeekStatus {
|
||||
DRAFT
|
||||
SUBMITTED
|
||||
APPROVED
|
||||
}
|
||||
|
||||
model StaffProfile {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
educatorId String @unique
|
||||
educator Educator @relation(fields: [educatorId], references: [id], onDelete: NoAction)
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
employmentStart DateTime @db.Date
|
||||
employmentEnd DateTime? @db.Date
|
||||
trackingStart DateTime @db.Date
|
||||
openingMinutes Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
workPatterns StaffWorkPattern[]
|
||||
dayOverrides StaffDayOverride[]
|
||||
leaveAccounts StaffLeaveAccount[]
|
||||
leaves StaffLeave[]
|
||||
timeEntries StaffTimeEntry[]
|
||||
weeks StaffWeek[]
|
||||
adjustments StaffAdjustment[]
|
||||
history StaffHistory[]
|
||||
notifications StaffNotification[]
|
||||
emails StaffEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("staff_profiles")
|
||||
}
|
||||
|
||||
// Persisted in the same transaction as the staff action; delivered after commit.
|
||||
model StaffEmail {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
toAdmins Boolean @default(false)
|
||||
subject String
|
||||
message String
|
||||
path String
|
||||
inviteToken String?
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
attempts Int @default(0)
|
||||
failed Boolean @default(false)
|
||||
|
||||
@@index([profileId, sentAt, skippedAt])
|
||||
@@map("staff_emails")
|
||||
}
|
||||
|
||||
model StaffWorkPattern {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
effectiveFrom DateTime @db.Date
|
||||
// Montag bis Sonntag; 0 bedeutet arbeitsfrei.
|
||||
dailyMinutes Int[]
|
||||
|
||||
@@unique([profileId, effectiveFrom])
|
||||
@@map("staff_work_patterns")
|
||||
}
|
||||
|
||||
model StaffDayOverride {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
minutes Int
|
||||
reason String
|
||||
|
||||
@@unique([profileId, date])
|
||||
@@map("staff_day_overrides")
|
||||
}
|
||||
|
||||
model StaffHoliday {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
name String
|
||||
|
||||
@@unique([kitaId, date])
|
||||
@@map("staff_holidays")
|
||||
}
|
||||
|
||||
model StaffLeaveAccount {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
year Int
|
||||
entitlementUnits Int
|
||||
carryUnits Int @default(0)
|
||||
|
||||
@@unique([profileId, year])
|
||||
@@map("staff_leave_accounts")
|
||||
}
|
||||
|
||||
model StaffLeave {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
kind StaffLeaveKind
|
||||
status StaffRequestStatus @default(PENDING)
|
||||
startDate DateTime @db.Date
|
||||
endDate DateTime @db.Date
|
||||
halfDay Boolean @default(false)
|
||||
minutes Int?
|
||||
note String?
|
||||
decisionNote String?
|
||||
requestedById String
|
||||
reviewedById String?
|
||||
reviewedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
days StaffLeaveDay[]
|
||||
|
||||
@@index([profileId, status, startDate])
|
||||
@@map("staff_leaves")
|
||||
}
|
||||
|
||||
model StaffLeaveDay {
|
||||
id String @id @default(cuid())
|
||||
leaveId String
|
||||
leave StaffLeave @relation(fields: [leaveId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
vacationUnits Int @default(0)
|
||||
creditMinutes Int
|
||||
|
||||
@@unique([leaveId, date])
|
||||
@@map("staff_leave_days")
|
||||
}
|
||||
|
||||
model StaffTimeEntry {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
startMinute Int
|
||||
endMinute Int
|
||||
breakMinutes Int @default(0)
|
||||
note String?
|
||||
|
||||
@@index([profileId, date])
|
||||
@@map("staff_time_entries")
|
||||
}
|
||||
|
||||
model StaffWeek {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
weekStart DateTime @db.Date
|
||||
status StaffWeekStatus @default(DRAFT)
|
||||
submittedAt DateTime?
|
||||
reviewedAt DateTime?
|
||||
reviewedById String?
|
||||
decisionNote String?
|
||||
// Snapshot von Soll-, Ist- und Abwesenheitsminuten bei Genehmigung.
|
||||
dailySnapshot Json?
|
||||
|
||||
@@unique([profileId, weekStart])
|
||||
@@map("staff_weeks")
|
||||
}
|
||||
|
||||
model StaffAdjustment {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
minutes Int
|
||||
reason String
|
||||
actorId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([profileId, date])
|
||||
@@map("staff_adjustments")
|
||||
}
|
||||
|
||||
model StaffHistory {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
actorId String
|
||||
action String
|
||||
details String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([profileId, createdAt])
|
||||
@@map("staff_history")
|
||||
}
|
||||
|
||||
model StaffNotification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
message String
|
||||
readAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, readAt, createdAt])
|
||||
@@map("staff_notifications")
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// PARENT DUTIES (Feste Ämter / Elterndienste, Modul 3)
|
||||
// =====================================================================
|
||||
@@ -430,8 +689,9 @@ model Announcement {
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
title String
|
||||
content String
|
||||
title String
|
||||
content String
|
||||
audience AnnouncementAudience @default(ALLE)
|
||||
|
||||
authorId String
|
||||
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||
@@ -528,6 +788,7 @@ model Termin {
|
||||
|
||||
/// Teilnahme-Abfrage für Ausflüge und besondere Events.
|
||||
requiresRsvp Boolean @default(false)
|
||||
rsvpMode RsvpMode @default(CHILD)
|
||||
rsvpDeadline DateTime?
|
||||
participantInfo String?
|
||||
|
||||
@@ -542,8 +803,9 @@ model Termin {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mitbringselItems MitbringselItem[]
|
||||
participations EventParticipation[]
|
||||
mitbringselItems MitbringselItem[]
|
||||
participations EventParticipation[]
|
||||
familyParticipations FamilyEventParticipation[]
|
||||
|
||||
@@index([kitaId, startDate])
|
||||
@@index([kitaId, status])
|
||||
@@ -573,6 +835,25 @@ model EventParticipation {
|
||||
@@map("event_participations")
|
||||
}
|
||||
|
||||
model FamilyEventParticipation {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
eventId String
|
||||
event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
status EventParticipationStatus
|
||||
adultCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([eventId, familyId])
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@map("family_event_participations")
|
||||
}
|
||||
|
||||
model MitbringselItem {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
@@ -671,7 +952,8 @@ model NotdienstAssignment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
alerts NotdienstAlert[]
|
||||
alerts NotdienstAlert[]
|
||||
alarms NotdienstAlarm[]
|
||||
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
|
||||
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
|
||||
|
||||
@@ -700,11 +982,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())
|
||||
@@ -719,10 +1001,117 @@ model NotdienstSwapRequest {
|
||||
|
||||
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
|
||||
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
|
||||
model NotdienstAlarm {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
assignmentId String?
|
||||
assignment NotdienstAssignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull)
|
||||
// Nullable unique reservation: cancelled or ended alarms release the assignment.
|
||||
activeAssignmentId String? @unique
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
familyName String
|
||||
childName String
|
||||
date DateTime @db.Date
|
||||
wholeDay Boolean @default(true)
|
||||
startMinute Int?
|
||||
endMinute Int?
|
||||
expiresAt DateTime
|
||||
deadline DateTime
|
||||
status NotdienstAlarmStatus @default(PENDING)
|
||||
triggeredById String?
|
||||
triggeredAt DateTime @default(now())
|
||||
respondedById String?
|
||||
respondedAt DateTime?
|
||||
responseSource String?
|
||||
escalatedAt DateTime?
|
||||
educatorId String?
|
||||
notes String?
|
||||
cancelledAt DateTime?
|
||||
cancelReason String?
|
||||
recipients NotdienstAlarmRecipient[]
|
||||
notifications NotdienstAlarmNotification[]
|
||||
emails NotdienstAlarmEmail[]
|
||||
history NotdienstAlarmHistory[]
|
||||
legacyAlerts NotdienstAlert[]
|
||||
|
||||
@@index([kitaId, date])
|
||||
@@index([status, deadline])
|
||||
@@map("notdienst_alarms")
|
||||
}
|
||||
|
||||
model NotdienstAlarmRecipient {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
sessionVersion Int @default(0)
|
||||
|
||||
@@unique([alarmId, userId])
|
||||
@@map("notdienst_alarm_recipients")
|
||||
}
|
||||
|
||||
model NotdienstAlarmNotification {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
eventKey String
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
readAt DateTime?
|
||||
|
||||
@@unique([alarmId, userId, eventKey])
|
||||
@@index([userId, readAt])
|
||||
@@map("notdienst_alarm_notifications")
|
||||
}
|
||||
|
||||
model NotdienstAlarmEmail {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
eventKey String
|
||||
kind String
|
||||
subject String
|
||||
message String
|
||||
path String
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
failed Boolean @default(false)
|
||||
attempts Int @default(0)
|
||||
|
||||
@@unique([alarmId, userId, eventKey])
|
||||
@@index([sentAt, skippedAt, createdAt])
|
||||
@@map("notdienst_alarm_emails")
|
||||
}
|
||||
|
||||
model NotdienstAlarmHistory {
|
||||
id String @id @default(cuid())
|
||||
alarmId String
|
||||
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
|
||||
actorId String?
|
||||
action String
|
||||
details String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([alarmId, createdAt])
|
||||
@@map("notdienst_alarm_history")
|
||||
}
|
||||
|
||||
model NotdienstAlert {
|
||||
id String @id @default(cuid())
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
alarmId String?
|
||||
alarm NotdienstAlarm? @relation(fields: [alarmId], references: [id], onDelete: SetNull)
|
||||
kitaId String
|
||||
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
|
||||
|
||||
assignmentId String
|
||||
assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
@@ -771,13 +1160,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])
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RsvpMode" AS ENUM ('CHILD', 'FAMILY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnnouncementAudience" AS ENUM ('ELTERN', 'ERZIEHER', 'ALLE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "announcements" ADD COLUMN "audience" "AnnouncementAudience" NOT NULL DEFAULT 'ALLE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "termine" ADD COLUMN "rsvpMode" "RsvpMode" NOT NULL DEFAULT 'CHILD';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "family_event_participations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kitaId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"familyId" TEXT NOT NULL,
|
||||
"status" "EventParticipationStatus" NOT NULL,
|
||||
"adultCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "family_event_participations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "family_event_participations_kitaId_idx" ON "family_event_participations"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "family_event_participations_familyId_idx" ON "family_event_participations"("familyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "family_event_participations_eventId_familyId_key" ON "family_event_participations"("eventId", "familyId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "family_event_participations" ADD CONSTRAINT "family_event_participations_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, chownSync } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const passwordFile = process.env.APP_DATABASE_PASSWORD_FILE;
|
||||
if (!passwordFile) throw new Error('Runtime password file is required.');
|
||||
const directory = passwordFile.slice(0, passwordFile.lastIndexOf('/'));
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const password = existsSync(passwordFile) ? readFileSync(passwordFile, 'utf8').trim() : randomBytes(32).toString('hex');
|
||||
if (!/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid runtime password file.');
|
||||
if (!existsSync(passwordFile)) writeFileSync(passwordFile, password + '\n', { flag: 'wx', mode: 0o400 });
|
||||
chmodSync(passwordFile, 0o400);
|
||||
chownSync(passwordFile, 1001, 1001);
|
||||
chownSync(directory, 1001, 1001);
|
||||
const ownerUrl = new URL('postgresql://postgres:5432/' + encodeURIComponent(process.env.POSTGRES_DB || 'kita_planer'));
|
||||
ownerUrl.username = process.env.POSTGRES_USER || 'kita_planer';
|
||||
ownerUrl.password = process.env.POSTGRES_PASSWORD || '';
|
||||
if (!ownerUrl.password) throw new Error('Database owner password is required.');
|
||||
const environment = { ...process.env, DATABASE_URL: ownerUrl.toString(), APP_DATABASE_PASSWORD: password };
|
||||
for (const args of [['node_modules/prisma/build/index.js', 'migrate', 'deploy'], ['scripts/provision-runtime-role.mjs']]) {
|
||||
const result = spawnSync(process.execPath, args, { env: environment, stdio: 'inherit' });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
for (const path of ['/app/uploads', '/app/uploads/announcements', '/app/uploads/announcements/announcements', '/app/runtime-cache']) {
|
||||
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||
chownSync(path, 1001, 1001);
|
||||
}
|
||||
console.log('Database initialized; restricted application access and volumes are ready.');
|
||||
@@ -0,0 +1,42 @@
|
||||
// Runs inside the application container. No external cron service or browser is required.
|
||||
const secret = process.env.NOTDIENST_JOB_SECRET;
|
||||
if (!secret || secret.length < 32) {
|
||||
console.error(
|
||||
"Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1);
|
||||
let stopped = false;
|
||||
let timer;
|
||||
async function tick() {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/jobs/notdienstalarme`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${secret}` },
|
||||
signal: AbortSignal.timeout(50000),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
console.error(
|
||||
`Notdienst-Fristprüfung: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
|
||||
);
|
||||
} catch {
|
||||
console.error(
|
||||
"Notdienst-Fristprüfung momentan nicht erreichbar; erneuter Versuch in einer Minute.",
|
||||
);
|
||||
}
|
||||
if (!stopped)
|
||||
timer = setTimeout(tick, Math.max(1000, 60000 - (Date.now() - started)));
|
||||
}
|
||||
for (const signal of ["SIGINT", "SIGTERM"])
|
||||
process.on(signal, () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
process.exit(0);
|
||||
});
|
||||
timer = setTimeout(tick, 5000);
|
||||
@@ -1,3 +1,7 @@
|
||||
import { cpSync } from 'node:fs';
|
||||
import { cpSync, mkdirSync } from 'node:fs';
|
||||
cpSync('.next/static', '.next/standalone/.next/static', { recursive: true });
|
||||
cpSync('public', '.next/standalone/public', { recursive: true });
|
||||
mkdirSync('.next/standalone/scripts', { recursive: true });
|
||||
cpSync('scripts/start-production.sh', '.next/standalone/scripts/start-production.sh');
|
||||
cpSync('scripts/notdienst-alarm-worker.mjs', '.next/standalone/scripts/notdienst-alarm-worker.mjs');
|
||||
cpSync('scripts/runtime-database-url.mjs', '.next/standalone/scripts/runtime-database-url.mjs');
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
const file = process.env.APP_DATABASE_PASSWORD_FILE;
|
||||
if (!file) throw new Error('Runtime password file is required.');
|
||||
const password = readFileSync(file, 'utf8').trim();
|
||||
if (!/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid runtime password file.');
|
||||
const url = new URL('postgresql://kita_runtime@postgres:5432/' + encodeURIComponent(process.env.APP_DATABASE_NAME || 'kita_planer'));
|
||||
url.password = password;
|
||||
process.stdout.write(url.toString());
|
||||
@@ -1,4 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Apply reviewed migrations separately before starting the runtime.
|
||||
cd "$(dirname "$0")/.."
|
||||
if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then
|
||||
DATABASE_URL="$(node scripts/runtime-database-url.mjs)"
|
||||
export DATABASE_URL
|
||||
fi
|
||||
# Schema changes are applied by the maintenance image before runtime startup.
|
||||
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
|
||||
node scripts/notdienst-alarm-worker.mjs &
|
||||
fi
|
||||
exec node server.js
|
||||
|
||||
@@ -7,13 +7,17 @@ import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { AnnouncementAudience, UserRole, type Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { NewsEmail } from "@/emails/NewsEmail";
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
announcementRecipientWhere,
|
||||
visibleAnnouncementAudiences,
|
||||
} from "@/lib/announcement-access";
|
||||
|
||||
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
|
||||
|
||||
@@ -29,24 +33,7 @@ function getSafeFileName(name: string) {
|
||||
}
|
||||
|
||||
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
|
||||
const where: Prisma.UserWhereInput =
|
||||
audience === "ELTERN"
|
||||
? {
|
||||
kitaId,
|
||||
familyId: { not: null },
|
||||
emailVerifiedAt: { not: null },
|
||||
}
|
||||
: audience === "ERZIEHER"
|
||||
? {
|
||||
kitaId,
|
||||
role: UserRole.ERZIEHER,
|
||||
emailVerifiedAt: { not: null },
|
||||
}
|
||||
: {
|
||||
kitaId,
|
||||
emailVerifiedAt: { not: null },
|
||||
OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }],
|
||||
};
|
||||
const where: Prisma.UserWhereInput = { kitaId, emailVerifiedAt: { not: null }, ...announcementRecipientWhere(audience as AnnouncementAudience) };
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { ...where, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
@@ -118,6 +105,7 @@ export async function createAnnouncement(formData: FormData) {
|
||||
kitaId,
|
||||
title: parsed.data.title,
|
||||
content: parsed.data.content,
|
||||
audience: parsed.data.audience,
|
||||
authorId: session.user.id,
|
||||
attachments: {
|
||||
create: attachmentInputs.map((attachment) => ({
|
||||
@@ -158,7 +146,10 @@ export async function createAnnouncement(formData: FormData) {
|
||||
rm(attachmentPath(attachment.id), { force: true }),
|
||||
),
|
||||
);
|
||||
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
|
||||
console.error(
|
||||
"Ankündigungs-Anhang konnte nicht gespeichert werden:",
|
||||
error,
|
||||
);
|
||||
return { error: "Anhang konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
@@ -190,6 +181,7 @@ export async function createAnnouncement(formData: FormData) {
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/admin/news");
|
||||
revalidatePath("/dashboard/mitteilungen");
|
||||
|
||||
if (emailError) {
|
||||
return {
|
||||
@@ -208,6 +200,7 @@ export async function markAnnouncementRead(announcementId: string) {
|
||||
where: {
|
||||
id: announcementId,
|
||||
kitaId: session.user.kitaId,
|
||||
audience: { in: visibleAnnouncementAudiences(session.user) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -234,5 +227,6 @@ export async function markAnnouncementRead(announcementId: string) {
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/mitteilungen");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
+114
-77
@@ -3,20 +3,16 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
EventParticipationStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { rsvpSubmissionSchema } from "@/lib/rsvp";
|
||||
import { lockEvent } from "@/lib/event-lock";
|
||||
|
||||
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const submitRsvpSchema = z.object({
|
||||
eventId: z.string().min(1),
|
||||
childId: z.string().min(1),
|
||||
status: z.nativeEnum(EventParticipationStatus),
|
||||
});
|
||||
|
||||
export type EventParticipantDto = {
|
||||
childId: string;
|
||||
childName: string;
|
||||
@@ -24,79 +20,120 @@ export type EventParticipantDto = {
|
||||
status: EventParticipationStatus | null;
|
||||
};
|
||||
|
||||
export async function submitEventRsvp(rawPayload: unknown) {
|
||||
export async function submitEventRsvp(
|
||||
rawPayload: unknown,
|
||||
): Promise<{ error?: string; success?: boolean }> {
|
||||
const session = await requireKitaSession();
|
||||
const parsed = submitRsvpSchema.safeParse(rawPayload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
const parsed = rsvpSubmissionSchema.safeParse(rawPayload);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
error:
|
||||
"Bitte alle Antworten und bei Familienzusagen die Anzahl Erwachsener angeben.",
|
||||
};
|
||||
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER) {
|
||||
return {
|
||||
error: "Für Rückmeldungen ist ein Elternkonto mit Haushalt erforderlich.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!session.user.familyId) {
|
||||
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
|
||||
const kitaId = session.user.kitaId;
|
||||
const familyId = session.user.familyId;
|
||||
const payload = parsed.data;
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await lockEvent(tx, payload.eventId, kitaId);
|
||||
const event = await tx.termin.findFirst({
|
||||
where: { id: payload.eventId, kitaId },
|
||||
select: {
|
||||
status: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
rsvpDeadline: true,
|
||||
startDate: true,
|
||||
kita: { select: { terminModuleEnabled: true } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!event?.kita.terminModuleEnabled ||
|
||||
!event.requiresRsvp ||
|
||||
event.status !== TerminStatus.CONFIRMED
|
||||
) {
|
||||
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
|
||||
}
|
||||
if (event.rsvpMode !== payload.mode)
|
||||
return {
|
||||
error: "Die Antwortart wurde geändert. Bitte lade den Termin erneut.",
|
||||
};
|
||||
if (payload.mode === RsvpMode.FAMILY) {
|
||||
const family = await tx.family.findFirst({
|
||||
where: { id: familyId, kitaId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!family) return { error: "Haushalt wurde nicht gefunden." };
|
||||
if (
|
||||
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
|
||||
event.startDate <= new Date()
|
||||
) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
await tx.familyEventParticipation.upsert({
|
||||
where: { eventId_familyId: { eventId: payload.eventId, familyId } },
|
||||
create: {
|
||||
kitaId,
|
||||
eventId: payload.eventId,
|
||||
familyId,
|
||||
status: payload.status,
|
||||
adultCount: payload.adultCount,
|
||||
},
|
||||
update: { status: payload.status, adultCount: payload.adultCount },
|
||||
});
|
||||
} else {
|
||||
const children = await tx.child.findMany({
|
||||
where: { kitaId, familyId, active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = new Set(children.map((child) => child.id));
|
||||
if (
|
||||
payload.responses.length !== ids.size ||
|
||||
payload.responses.some((row) => !ids.has(row.childId))
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"Bitte antworte für alle eigenen aktiven Kinder. Lade den Termin bei Änderungen erneut.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
|
||||
event.startDate <= new Date()
|
||||
) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
for (const response of payload.responses) {
|
||||
await tx.eventParticipation.upsert({
|
||||
where: {
|
||||
eventId_childId: {
|
||||
eventId: payload.eventId,
|
||||
childId: response.childId,
|
||||
},
|
||||
},
|
||||
create: { kitaId, eventId: payload.eventId, ...response },
|
||||
update: { status: response.status },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
if (result.error) return result;
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Rückmeldung konnte nicht gespeichert werden:", error);
|
||||
return {
|
||||
error:
|
||||
"Rückmeldung konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
};
|
||||
}
|
||||
|
||||
const { eventId, childId, status } = parsed.data;
|
||||
|
||||
const event = await prisma.termin.findFirst({
|
||||
where: {
|
||||
id: eventId,
|
||||
kitaId: session.user.kitaId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
requiresRsvp: true,
|
||||
rsvpDeadline: true,
|
||||
startDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!event || !event.requiresRsvp || event.status !== TerminStatus.CONFIRMED) {
|
||||
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
|
||||
}
|
||||
|
||||
const deadline = event.rsvpDeadline ?? event.startDate;
|
||||
if (deadline < new Date()) {
|
||||
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
|
||||
}
|
||||
|
||||
const child = await prisma.child.findFirst({
|
||||
where: {
|
||||
id: childId,
|
||||
kitaId: session.user.kitaId,
|
||||
familyId: session.user.familyId,
|
||||
active: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
|
||||
}
|
||||
|
||||
await prisma.eventParticipation.upsert({
|
||||
where: {
|
||||
eventId_childId: {
|
||||
eventId,
|
||||
childId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
kitaId: session.user.kitaId,
|
||||
eventId,
|
||||
childId,
|
||||
status,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getEventParticipants(
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
"use server";
|
||||
import { publicActionError } from "@/lib/action-error";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { activeAlertTokenWhere } from "@/lib/alert-tokens";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
import { respondToAlarm } from "@/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { hashToken, isPublicToken } from "@/lib/tokens";
|
||||
|
||||
export async function respondAlertAction(token: string, approve: boolean) {
|
||||
if (!isPublicToken(token) || typeof approve !== "boolean")
|
||||
return { error: "Ungültiger Rückmeldelink." };
|
||||
if (!(await limitPublicAction("alarm-response", hashToken(token), 12, 60)))
|
||||
return { error: "Bitte kurz warten und erneut versuchen." };
|
||||
try {
|
||||
const result = await respondToAlarm(token, approve);
|
||||
// The household response is already committed; transport failure must not undo it.
|
||||
try {
|
||||
await deliverAlarmEmails(result.emailIds);
|
||||
} catch {
|
||||
/* Persistent jobs will retry. */
|
||||
}
|
||||
revalidatePath("/alert/[token]", "page");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = publicActionError(error, "");
|
||||
if (message) return { error: message };
|
||||
return {
|
||||
error:
|
||||
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function confirmAlertAction(token: string) {
|
||||
if (!isPublicToken(token)) return { error: "Ungültiger Bestätigungs-Link." };
|
||||
if (!(await limitPublicAction("alert-confirm", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." };
|
||||
const updated = await prisma.notdienstAlert.updateMany({
|
||||
where: activeAlertTokenWhere(token),
|
||||
data: { status: NotdienstAlertStatus.CONFIRMED, confirmedAt: new Date() },
|
||||
});
|
||||
if (updated.count !== 1) return { error: "Dieser Link ist abgelaufen oder wurde bereits verwendet." };
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
return respondAlertAction(token, true);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmAlertAction } from "./actions";
|
||||
import { respondAlertAction } from "./actions";
|
||||
|
||||
export function AlertConfirmForm({ token }: { token: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
startTransition(async () => {
|
||||
const result = await confirmAlertAction(token);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
toast.success("Erfolgreich bestätigt!");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const [choice, setChoice] = useState<boolean | null>(null);
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto font-semibold text-lg"
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : null}
|
||||
Ich bestätige! (Bin auf dem Weg)
|
||||
</Button>
|
||||
<div className="grid gap-4">
|
||||
{choice === null ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button onClick={() => setChoice(true)}>Wir übernehmen</Button>
|
||||
<Button variant="outline" onClick={() => setChoice(false)}>
|
||||
Wir können nicht
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<p className="text-sm">
|
||||
{choice
|
||||
? "Wir sagen für unseren Haushalt zu."
|
||||
: "Unser Haushalt kann diesen Einsatz nicht übernehmen."}{" "}
|
||||
Diese Antwort gilt für alle Elternkonten des Haushalts.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
setError("");
|
||||
const result = await respondAlertAction(token, choice);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
router.refresh();
|
||||
} else router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Wird gespeichert …" : "Antwort verbindlich speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setChoice(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+99
-116
@@ -1,131 +1,114 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
|
||||
import { activeAlertTokenWhere } from "@/lib/alert-tokens";
|
||||
import { isPublicToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BellRing, CheckCircle2 } from "lucide-react";
|
||||
import { getPublicAlarm } from "@/lib/alarms/service";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
alarmLabel,
|
||||
dateKey,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
} from "@/lib/alarms/format";
|
||||
import { AlertConfirmForm } from "./alert-confirm-form";
|
||||
|
||||
export const metadata = {
|
||||
title: "Notdienstalarm · Kita-Planer",
|
||||
robots: { index: false, follow: false },
|
||||
referrer: "no-referrer" as const,
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer", robots: { index: false, follow: false } };
|
||||
|
||||
export default async function AlertPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
if (!isPublicToken(token)) notFound();
|
||||
const alert = await prisma.notdienstAlert.findFirst({
|
||||
where: activeAlertTokenWhere(token),
|
||||
select: {
|
||||
status: true,
|
||||
parentUser: {
|
||||
select: {
|
||||
firstName: true,
|
||||
},
|
||||
},
|
||||
kita: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
assignment: {
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isPending = alert.status === NotdienstAlertStatus.PENDING;
|
||||
const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED;
|
||||
|
||||
const alarm = await getPublicAlarm(token);
|
||||
if (!alarm) notFound();
|
||||
const now = new Date(),
|
||||
expired = alarm.expiresAt <= now;
|
||||
const canRespond = alarm.status === "PENDING" && !expired;
|
||||
const overdue = alarm.deadline <= now;
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12">
|
||||
<Card className="w-full max-w-md shadow-lg border-primary/20">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{isPending ? (
|
||||
<BellRing className="h-8 w-8 animate-pulse text-destructive" />
|
||||
) : isConfirmed ? (
|
||||
<ShieldCheck className="h-8 w-8 text-success" />
|
||||
) : (
|
||||
<XCircle className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{isPending
|
||||
? "Notdienst Einsatz!"
|
||||
: isConfirmed
|
||||
? "Einsatz bestätigt"
|
||||
: "Alarm abgebrochen"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base mt-2">
|
||||
Kita {alert.kita.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-4">
|
||||
<div className="rounded-lg bg-background border p-4 text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Datum</p>
|
||||
<p className="font-semibold text-lg">
|
||||
{format(alert.assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
</p>
|
||||
<div className="h-2" />
|
||||
<p className="text-sm text-muted-foreground">Eingeteiltes Kind</p>
|
||||
<p className="font-medium">
|
||||
{alert.assignment.child.firstName}{" "}
|
||||
{alert.assignment.child.lastName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
{isPending ? (
|
||||
<>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich
|
||||
krankgemeldet und du bist heute für den Notdienst eingeteilt.
|
||||
Bitte bestätige deinen Einsatz schnellstmöglich!
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||
<section className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] shadow-sm">
|
||||
<header
|
||||
className={`border-b border-[var(--hairline)] p-6 sm:p-8 ${alarm.status === "CONFIRMED" ? "bg-[var(--accent-soft)]" : "bg-[var(--surface-2)]"}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
|
||||
Kita-Planer · {alarm.kitaName}
|
||||
</p>
|
||||
{alarm.status === "CONFIRMED" ? (
|
||||
<CheckCircle2 className="mt-6 h-7 w-7 text-[var(--accent-deep)]" />
|
||||
) : (
|
||||
<BellRing className="mt-6 h-7 w-7 text-[var(--danger)]" />
|
||||
)}
|
||||
<h1 className="mt-3 text-3xl text-[var(--ink)]">
|
||||
{alarm.status === "PENDING"
|
||||
? "Könnt ihr den Notdienst übernehmen?"
|
||||
: alarmLabel(alarm)}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Hallo {alarm.firstName}, diese Rückmeldung gilt für euren gesamten
|
||||
Haushalt.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-6 p-6 sm:p-8">
|
||||
<dl className="grid grid-cols-2 gap-5 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Datum</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{dateKey(alarm.date).split("-").reverse().join(".")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Einsatz</dt>
|
||||
<dd className="mt-1 font-semibold">{durationLabel(alarm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Haushalt</dt>
|
||||
<dd className="mt-1 font-semibold">{alarm.familyName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Antwortfrist</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{instantLabel(alarm.deadline)} Uhr
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{canRespond ? (
|
||||
<>
|
||||
{overdue && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||
Die Antwortfrist ist abgelaufen. Ihr könnt noch antworten.
|
||||
Eine verspätete Zusage muss die Koordination freigeben, da
|
||||
möglicherweise bereits Ersatz organisiert wird.
|
||||
</p>
|
||||
<AlertConfirmForm token={token} />
|
||||
</>
|
||||
) : isConfirmed ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Danke, {alert.parentUser.firstName}! Dein Einsatz wurde
|
||||
erfolgreich bestätigt. Die Kita rechnet mit dir.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht
|
||||
einspringen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<AlertConfirmForm token={token} />
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-xl border border-[var(--hairline)] p-4 text-sm leading-6"
|
||||
>
|
||||
{alarm.status === "CANCELLED"
|
||||
? "Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen."
|
||||
: alarm.status === "CONFIRMED"
|
||||
? "Euer Haushalt hat zugesagt. Die Kita rechnet mit euch. Bei einer Änderung kontaktiert bitte die Koordination."
|
||||
: alarm.status === "LATE_CONFIRMATION"
|
||||
? "Eure verspätete Zusage ist gespeichert. Wartet bitte auf die Freigabe durch die Koordination."
|
||||
: alarm.status === "DECLINED"
|
||||
? "Die Absage eures Haushalts ist gespeichert. Die Koordination kümmert sich um Ersatz."
|
||||
: alarm.status === "REVIEW_REQUIRED"
|
||||
? "Die bisherigen Rückmeldungen müssen von der Koordination geprüft werden. Bitte kontaktiert die Kita."
|
||||
: "Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen."}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
Den persönlichen Link bitte innerhalb eures Haushalts behalten.
|
||||
Änderungen einer gespeicherten Antwort klärt ihr mit der Kita.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { getKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -26,6 +27,7 @@ export async function GET(
|
||||
id,
|
||||
announcement: {
|
||||
kitaId: user.kitaId,
|
||||
audience: { in: visibleAnnouncementAudiences(user) },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function POST(request: Request) {
|
||||
const status = authorizeAlarmJob(request.headers.get("authorization"));
|
||||
if (status !== 200)
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
status === 503
|
||||
? "Fristprüfung ist nicht konfiguriert."
|
||||
: "Nicht autorisiert.",
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
try {
|
||||
const result = await runAlarmJob();
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
return Response.json(result);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Fristprüfung fehlgeschlagen; nächster Aufruf versucht es erneut.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { createElement } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus, UserRole } from "@prisma/client";
|
||||
|
||||
import { AlertEmail } from "@/emails/AlertEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
import { alarmAction } from "./notdienst/alarm/actions";
|
||||
// Existing callers use the shared household workflow and the same guards.
|
||||
export async function triggerAlertAction(assignmentId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
if (!(await rateLimit(`alerts:${kitaId}:${session.user.id}`, 50, 3600)).allowed) return { error: "Zu viele Alarme. Bitte versuche es später erneut." };
|
||||
try {
|
||||
const [existing, assignment] = await Promise.all([
|
||||
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
|
||||
prisma.notdienstAlert.findFirst({
|
||||
where: { assignmentId, kitaId },
|
||||
}),
|
||||
prisma.notdienstAssignment.findFirst({
|
||||
where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } },
|
||||
include: {
|
||||
child: {
|
||||
select: {
|
||||
id: true,
|
||||
familyId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (existing) {
|
||||
return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." };
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const parents = assignment.child.family.users;
|
||||
|
||||
if (parents.length === 0) {
|
||||
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
|
||||
}
|
||||
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { error: mailConfigError };
|
||||
}
|
||||
|
||||
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
const alertJobs = parents.map(parent => ({ email: parent.email, token: newToken(), parentId: parent.id }));
|
||||
await serializableTransaction(async tx => {
|
||||
if (await tx.notdienstAlert.findFirst({ where: { assignmentId, kitaId }, select: { id: true } })) {
|
||||
throw new UserFacingError("Für diese Einteilung wurde bereits ein Alarm ausgelöst.");
|
||||
}
|
||||
const current = await tx.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, select: { childId: true, date: true } });
|
||||
const today = new Date(); today.setUTCHours(0, 0, 0, 0);
|
||||
if (!current || current.childId !== assignment.child.id || current.date < today) throw new UserFacingError("Die Einteilung hat sich geändert oder liegt in der Vergangenheit. Bitte lade die Seite neu.");
|
||||
for (const job of alertJobs) {
|
||||
const parent = await tx.user.findFirst({ where: { id: job.parentId, kitaId, familyId: assignment.child.familyId,
|
||||
email: job.email, disabledAt: null, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, select: { id: true } });
|
||||
if (!parent) throw new UserFacingError("Die Empfänger haben sich geändert. Bitte lade die Seite neu.");
|
||||
await tx.notdienstAlert.create({ data: { kitaId, assignmentId, parentUserId: parent.id,
|
||||
triggeredById: session.user.id, status: NotdienstAlertStatus.PENDING, confirmationToken: hashToken(job.token) } });
|
||||
}
|
||||
});
|
||||
|
||||
for (const job of alertJobs) {
|
||||
const emailResult = await sendAppEmail({
|
||||
to: job.email,
|
||||
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
|
||||
react: createElement(AlertEmail, {
|
||||
date: dateLabel,
|
||||
childName,
|
||||
confirmLink: `${BASE_URL}/alert/${job.token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
revalidatePath("/dashboard");
|
||||
return {
|
||||
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: publicActionError(error, "Fehler beim Auslösen des Alarms.") };
|
||||
}
|
||||
return alarmAction("create", {
|
||||
assignmentId,
|
||||
wholeDay: true,
|
||||
replyMinutes: 30,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
EventParticipationStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
} from "@prisma/client";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -48,10 +50,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createTerminAdmin,
|
||||
updateTerminAdmin,
|
||||
} from "../../kalender/actions";
|
||||
import { createTerminAdmin, updateTerminAdmin } from "../../kalender/actions";
|
||||
|
||||
type AdminTerminDto = {
|
||||
id: string;
|
||||
@@ -64,6 +63,12 @@ type AdminTerminDto = {
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
familyParticipations: {
|
||||
familyId: string;
|
||||
status: EventParticipationStatus;
|
||||
adultCount: number;
|
||||
}[];
|
||||
rsvpDeadline: string | null;
|
||||
participantInfo: string | null;
|
||||
participations: {
|
||||
@@ -81,9 +86,11 @@ type ChildDto = {
|
||||
export function AdminCalendarManager({
|
||||
termine,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termine: AdminTerminDto[];
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const rsvpCount = termine.filter((termin) => termin.requiresRsvp).length;
|
||||
const pendingCount = termine.filter(
|
||||
@@ -104,8 +111,8 @@ export function AdminCalendarManager({
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Kind
|
||||
verfolgen.
|
||||
Termine verwalten, RSVP aktivieren und den Rücklauf pro Familie oder
|
||||
Kind verfolgen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
@@ -134,7 +141,7 @@ export function AdminCalendarManager({
|
||||
Noch keine Termine
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
Lege den ersten Termin an, um RSVP-Rückmeldungen einzusammeln.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -145,6 +152,7 @@ export function AdminCalendarManager({
|
||||
key={termin.id}
|
||||
termin={termin}
|
||||
childRows={childRows}
|
||||
familyRows={familyRows}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -156,11 +164,25 @@ export function AdminCalendarManager({
|
||||
function AdminTerminCard({
|
||||
termin,
|
||||
childRows,
|
||||
familyRows,
|
||||
}: {
|
||||
termin: AdminTerminDto;
|
||||
childRows: ChildDto[];
|
||||
familyRows: { id: string; name: string }[];
|
||||
}) {
|
||||
const stats = useMemo(() => getStats(termin, childRows), [termin, childRows]);
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
isFamily
|
||||
? familyRows.map((family) => ({
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
familyName: family.name,
|
||||
}))
|
||||
: childRows,
|
||||
[isFamily, familyRows, childRows],
|
||||
);
|
||||
const stats = useMemo(() => getStats(termin, rows), [termin, rows]);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
@@ -175,7 +197,9 @@ function AdminTerminCard({
|
||||
{getStatusLabel(termin.status)}
|
||||
</Badge>
|
||||
{termin.requiresRsvp && (
|
||||
<Badge variant="warning">RSVP aktiv</Badge>
|
||||
<Badge variant="warning">
|
||||
Antwort pro {isFamily ? "Familie" : "Kind"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg">{termin.title}</CardTitle>
|
||||
@@ -217,14 +241,19 @@ function AdminTerminCard({
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{stats.attending} Zugesagt / {stats.notAttending} Abgesagt /{" "}
|
||||
{stats.pending} Ausstehend
|
||||
{stats.pending} Ausstehend{" "}
|
||||
{isFamily ? `· ${stats.adultCount} Erwachsene` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Frist:{" "}
|
||||
{termin.rsvpDeadline
|
||||
? format(new Date(termin.rsvpDeadline), "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
})
|
||||
? format(
|
||||
new Date(termin.rsvpDeadline),
|
||||
"dd.MM.yyyy HH:mm",
|
||||
{
|
||||
locale: de,
|
||||
},
|
||||
)
|
||||
: "bis Terminbeginn"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -241,18 +270,24 @@ function AdminTerminCard({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead>Familie</TableHead>
|
||||
<TableHead>{isFamily ? "Familie" : "Kind"}</TableHead>
|
||||
<TableHead>{isFamily ? "Erwachsene" : "Familie"}</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childRows.map((child) => {
|
||||
{rows.map((child) => {
|
||||
const status = stats.statusByChildId.get(child.id) ?? null;
|
||||
return (
|
||||
<TableRow key={child.id}>
|
||||
<TableCell className="font-medium">{child.name}</TableCell>
|
||||
<TableCell>{child.familyName}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{child.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isFamily
|
||||
? (stats.adultCounts.get(child.id) ?? "–")
|
||||
: child.familyName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RsvpStatusBadge status={status} />
|
||||
</TableCell>
|
||||
@@ -283,6 +318,13 @@ function TerminFormDialog({
|
||||
const [requiresRsvp, setRequiresRsvp] = useState(
|
||||
termin?.requiresRsvp ?? false,
|
||||
);
|
||||
const [rsvpMode, setRsvpMode] = useState(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
const [modeTouched, setModeTouched] = useState(false);
|
||||
const modeLocked =
|
||||
!!termin &&
|
||||
termin.participations.length + termin.familyParticipations.length > 0;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleAction(formData: FormData) {
|
||||
@@ -300,6 +342,7 @@ function TerminFormDialog({
|
||||
allDay: formData.get("allDay") === "on",
|
||||
mitbringselListEnabled: formData.get("mitbringselListEnabled") === "on",
|
||||
requiresRsvp: hasRsvp,
|
||||
rsvpMode,
|
||||
rsvpDeadline:
|
||||
hasRsvp && deadlineRaw ? new Date(deadlineRaw).toISOString() : null,
|
||||
participantInfo: String(formData.get("participantInfo") ?? ""),
|
||||
@@ -330,6 +373,10 @@ function TerminFormDialog({
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setRequiresRsvp(termin?.requiresRsvp ?? false);
|
||||
setRsvpMode(
|
||||
termin?.rsvpMode ?? suggestedRsvpMode(TerminType.KITA_FEST),
|
||||
);
|
||||
setModeTouched(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -380,6 +427,12 @@ function TerminFormDialog({
|
||||
<select
|
||||
id={`${mode}-type`}
|
||||
name="type"
|
||||
onChange={(event) => {
|
||||
if (!termin && !modeLocked && !modeTouched)
|
||||
setRsvpMode(
|
||||
suggestedRsvpMode(event.target.value as TerminType),
|
||||
);
|
||||
}}
|
||||
defaultValue={termin?.type ?? TerminType.KITA_FEST}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:bg-[oklch(0.99_0.005_75)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
required
|
||||
@@ -453,9 +506,33 @@ function TerminFormDialog({
|
||||
{requiresRsvp && (
|
||||
<div className="grid gap-4 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>
|
||||
Rückmeldefrist
|
||||
</Label>
|
||||
<Label htmlFor={`${mode}-rsvpMode`}>Antwortart</Label>
|
||||
<select
|
||||
id={`${mode}-rsvpMode`}
|
||||
value={rsvpMode}
|
||||
disabled={modeLocked}
|
||||
onChange={(e) => {
|
||||
setRsvpMode(e.target.value as RsvpMode);
|
||||
setModeTouched(true);
|
||||
}}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="FAMILY">
|
||||
Pro Familie · mit Anzahl Erwachsener
|
||||
</option>
|
||||
<option value="CHILD">
|
||||
Pro Kind · einzelne Zu-/Absage
|
||||
</option>
|
||||
</select>
|
||||
{modeLocked ? (
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Antwortart ist gesperrt, da bereits Rückmeldungen
|
||||
vorliegen.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${mode}-rsvpDeadline`}>Rückmeldefrist</Label>
|
||||
<Input
|
||||
id={`${mode}-rsvpDeadline`}
|
||||
name="rsvpDeadline"
|
||||
@@ -532,28 +609,34 @@ function RsvpStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function getStats(termin: AdminTerminDto, children: ChildDto[]) {
|
||||
const statusByChildId = new Map(
|
||||
termin.participations.map((participation) => [
|
||||
participation.childId,
|
||||
participation.status,
|
||||
]),
|
||||
function getStats(termin: AdminTerminDto, rows: ChildDto[]) {
|
||||
const isFamily = termin.rsvpMode === RsvpMode.FAMILY;
|
||||
const statusByChildId = new Map<string, EventParticipationStatus>(
|
||||
isFamily
|
||||
? termin.familyParticipations.map((row) => [row.familyId, row.status])
|
||||
: termin.participations.map((row) => [row.childId, row.status]),
|
||||
);
|
||||
const adultCounts = new Map(
|
||||
termin.familyParticipations.map((row) => [row.familyId, row.adultCount]),
|
||||
);
|
||||
|
||||
let attending = 0;
|
||||
let notAttending = 0;
|
||||
|
||||
children.forEach((child) => {
|
||||
const status = statusByChildId.get(child.id);
|
||||
if (status === EventParticipationStatus.ATTENDING) attending += 1;
|
||||
let adultCount = 0;
|
||||
for (const row of rows) {
|
||||
const status = statusByChildId.get(row.id);
|
||||
if (status === EventParticipationStatus.ATTENDING) {
|
||||
attending += 1;
|
||||
adultCount += adultCounts.get(row.id) ?? 0;
|
||||
}
|
||||
if (status === EventParticipationStatus.NOT_ATTENDING) notAttending += 1;
|
||||
});
|
||||
|
||||
}
|
||||
return {
|
||||
attending,
|
||||
notAttending,
|
||||
pending: Math.max(children.length - attending - notAttending, 0),
|
||||
pending: rows.length - attending - notAttending,
|
||||
statusByChildId,
|
||||
adultCounts,
|
||||
adultCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function AdminKalenderPage() {
|
||||
return <div className="p-8">Kein Mandant zugeordnet.</div>;
|
||||
}
|
||||
|
||||
const [termine, children] = await Promise.all([
|
||||
const [termine, children, families] = await Promise.all([
|
||||
prisma.termin.findMany({
|
||||
where: { kitaId },
|
||||
select: {
|
||||
@@ -28,6 +28,10 @@ export default async function AdminKalenderPage() {
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
familyParticipations: {
|
||||
select: { familyId: true, status: true, adultCount: true },
|
||||
},
|
||||
rsvpDeadline: true,
|
||||
participantInfo: true,
|
||||
participations: {
|
||||
@@ -60,6 +64,11 @@ export default async function AdminKalenderPage() {
|
||||
{ firstName: "asc" },
|
||||
],
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: { kitaId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -70,6 +79,7 @@ export default async function AdminKalenderPage() {
|
||||
endDate: termin.endDate.toISOString(),
|
||||
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
|
||||
}))}
|
||||
familyRows={families}
|
||||
childRows={children.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
|
||||
@@ -64,7 +64,9 @@ export function NewsForm() {
|
||||
rows={10}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."}
|
||||
placeholder={
|
||||
"## Liebe Familien,\n\nhier stehen die offiziellen Informationen..."
|
||||
}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
@@ -110,21 +112,23 @@ export function NewsForm() {
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{sendEmail && (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<Label>Empfänger</Label>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Alle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 grid gap-2">
|
||||
<Label htmlFor="news-audience">Empfängergruppe</Label>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Bestimmt, wer die Meldung und Anhänge in der App sehen darf und
|
||||
optional die E-Mail erhält.
|
||||
</p>
|
||||
<Select name="audience" defaultValue="ELTERN">
|
||||
<SelectTrigger id="news-audience">
|
||||
<SelectValue placeholder="Empfänger auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ELTERN">Eltern</SelectItem>
|
||||
<SelectItem value="ERZIEHER">ErzieherInnen</SelectItem>
|
||||
<SelectItem value="ALLE">Eltern und ErzieherInnen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { announcementAudienceLabels } from "@/lib/announcement-access";
|
||||
import { NewsForm } from "./news-form";
|
||||
|
||||
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
|
||||
@@ -30,6 +31,7 @@ export default async function AdminNewsPage() {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
audience: true,
|
||||
createdAt: true,
|
||||
attachments: {
|
||||
select: {
|
||||
@@ -63,8 +65,8 @@ export default async function AdminNewsPage() {
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Veröffentliche verbindliche Ankündigungen für deine Kita. Anhänge
|
||||
werden geschützt gespeichert und nur für eingeloggte Nutzer
|
||||
derselben Kita ausgeliefert.
|
||||
werden geschützt gespeichert und nur an die gewählte Empfängergruppe
|
||||
deiner Kita ausgeliefert.
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
@@ -127,6 +129,9 @@ export default async function AdminNewsPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="mb-2">
|
||||
{announcementAudienceLabels[announcement.audience]}
|
||||
</Badge>
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
{format(announcement.createdAt, "dd.MM.yyyy HH:mm", {
|
||||
locale: de,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -29,18 +31,112 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
import { createEducator, updateEducator, deleteEducator } from "../actions";
|
||||
import {
|
||||
createEducator,
|
||||
updateEducator,
|
||||
deleteEducator,
|
||||
resendEducatorInvitation,
|
||||
} from "../actions";
|
||||
|
||||
type EducatorDto = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
hasProfile: boolean;
|
||||
email: string | null;
|
||||
verified: boolean;
|
||||
inviteFailed: boolean;
|
||||
};
|
||||
|
||||
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
function AccessFields({
|
||||
prefix,
|
||||
today,
|
||||
educator,
|
||||
}: {
|
||||
prefix: string;
|
||||
today: string;
|
||||
educator?: EducatorDto;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-email`}>E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id={`${prefix}-email`}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
defaultValue={educator?.email ?? ""}
|
||||
readOnly={!!educator?.email}
|
||||
required
|
||||
maxLength={254}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
{educator?.email
|
||||
? "Diese Adresse gehört zum verknüpften Account."
|
||||
: "An diese Adresse senden wir den persönlichen Aktivierungslink."}
|
||||
</p>
|
||||
</div>
|
||||
{!educator?.hasProfile && (
|
||||
<div className="grid gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-employmentStart`}>
|
||||
Beschäftigungsbeginn
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-employmentStart`}
|
||||
name="employmentStart"
|
||||
type="date"
|
||||
required
|
||||
min="2000-01-01"
|
||||
max="2100-12-31"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${prefix}-trackingStart`}>
|
||||
Start der Zeiterfassung
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-trackingStart`}
|
||||
name="trackingStart"
|
||||
type="date"
|
||||
defaultValue={today}
|
||||
required
|
||||
min="2000-01-01"
|
||||
max="2100-12-31"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)] sm:col-span-2">
|
||||
Ab diesem Datum werden Arbeitszeiten im Kita-Planer erfasst.
|
||||
Arbeitstage, Urlaub und vorhandene Überstunden richtest du
|
||||
anschließend im Personalprofil ein.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErzieherList({
|
||||
educators,
|
||||
today,
|
||||
}: {
|
||||
educators: EducatorDto[];
|
||||
today: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const filtered = educators.filter((ed) =>
|
||||
`${ed.firstName} ${ed.lastName} ${ed.email ?? ""}`
|
||||
.toLocaleLowerCase("de")
|
||||
.includes(search.toLocaleLowerCase("de")),
|
||||
);
|
||||
const [openCreate, setOpenCreate] = useState(false);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
|
||||
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
|
||||
null,
|
||||
);
|
||||
@@ -51,15 +147,24 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
firstName: formData.get("firstName") as string,
|
||||
lastName: formData.get("lastName") as string,
|
||||
active: formData.get("active") === "on",
|
||||
email: formData.get("email"),
|
||||
employmentStart: formData.get("employmentStart"),
|
||||
trackingStart: formData.get("trackingStart"),
|
||||
};
|
||||
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await createEducator(data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
setFormError(res.error);
|
||||
} else {
|
||||
toast.success("ErzieherIn angelegt.");
|
||||
toast.success(res.message ?? "Fachkraft und Personalprofil angelegt.");
|
||||
if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
setOpenCreate(false);
|
||||
if (res.educatorId)
|
||||
router.push(
|
||||
`/dashboard/personal?tab=verwaltung&educator=${res.educatorId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -70,19 +175,35 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
firstName: formData.get("firstName") as string,
|
||||
lastName: formData.get("lastName") as string,
|
||||
active: formData.get("active") === "on",
|
||||
email: formData.get("email"),
|
||||
employmentStart: formData.get("employmentStart"),
|
||||
trackingStart: formData.get("trackingStart"),
|
||||
};
|
||||
|
||||
setFormError("");
|
||||
startTransition(async () => {
|
||||
const res = await updateEducator(editingEducator.id, data);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
setFormError(res.error);
|
||||
} else {
|
||||
toast.success("Daten aktualisiert.");
|
||||
toast.success(res.message ?? "Daten aktualisiert.");
|
||||
if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
setEditingEducator(null);
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResend = (educator: EducatorDto) => {
|
||||
startTransition(async () => {
|
||||
const res = await resendEducatorInvitation(educator.id);
|
||||
if (res.error) toast.error(res.error);
|
||||
else if (res.warning) toast.warning(res.warning, { duration: 12000 });
|
||||
else toast.success(res.message ?? "Einladung erneuert.");
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (educator: EducatorDto) => {
|
||||
startTransition(async () => {
|
||||
const res = await deleteEducator(educator.id);
|
||||
@@ -100,24 +221,44 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
|
||||
<Input placeholder="Name suchen" className="pl-9" />
|
||||
<Input
|
||||
placeholder="Name oder E-Mail suchen"
|
||||
aria-label="Team durchsuchen"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
|
||||
<Dialog
|
||||
open={openCreate}
|
||||
onOpenChange={(open) => {
|
||||
if (!isPending) {
|
||||
setOpenCreate(open);
|
||||
setFormError("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4 [stroke-width:1.7]" />
|
||||
ErzieherIn anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleCreate}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleCreate(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue(n) ErzieherIn anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erfasse die Stammdaten des Kita-Personals.
|
||||
Lege Stammdaten, Personalprofil und App-Zugang gemeinsam an.
|
||||
Die Fachkraft erhält ihren Aktivierungslink per E-Mail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<fieldset disabled={isPending} className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="firstName">Vorname</Label>
|
||||
<Input id="firstName" name="firstName" required />
|
||||
@@ -126,14 +267,31 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
<Label htmlFor="lastName">Nachname</Label>
|
||||
<Input id="lastName" name="lastName" required />
|
||||
</div>
|
||||
<AccessFields prefix="create" today={today} />
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch id="active" name="active" defaultChecked />
|
||||
<Label htmlFor="active">Aktiv (Arbeitet aktuell hier)</Label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Speichern</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setOpenCreate(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? "Wird angelegt …"
|
||||
: "Fachkraft anlegen und einladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -153,91 +311,188 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">Vorname</TableHead>
|
||||
<TableHead>Nachname</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{educators.length === 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Plus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Keine Einträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege die erste Person im Team an.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableHead className="pl-6">Vorname</TableHead>
|
||||
<TableHead>Nachname</TableHead>
|
||||
<TableHead>E-Mail / App-Zugang</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="pr-6 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
educators.map((ed) => (
|
||||
<TableRow key={ed.id}>
|
||||
<TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
|
||||
<TableCell>{ed.lastName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ed.active ? "success" : "secondary"}>
|
||||
{ed.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setEducatorToDelete(ed)}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center">
|
||||
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Plus className="h-6 w-6 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">
|
||||
Keine Einträge gefunden
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Lege die erste Person im Team an.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
filtered.map((ed) => (
|
||||
<TableRow key={ed.id}>
|
||||
<TableCell className="pl-6 font-medium">
|
||||
{ed.firstName}
|
||||
</TableCell>
|
||||
<TableCell>{ed.lastName}</TableCell>
|
||||
<TableCell>
|
||||
<p className="mb-1 text-sm">
|
||||
{ed.email ?? "Noch keine E-Mail hinterlegt"}
|
||||
</p>
|
||||
<Badge variant={ed.verified ? "success" : "secondary"}>
|
||||
{ed.verified
|
||||
? "Account aktiviert"
|
||||
: ed.inviteFailed
|
||||
? "Mailversand fehlgeschlagen"
|
||||
: ed.email
|
||||
? "Einladung ausstehend"
|
||||
: "Zugang fehlt"}
|
||||
</Badge>
|
||||
{ed.email && !ed.verified && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => handleResend(ed)}
|
||||
aria-label={`Einladung für ${ed.firstName} ${ed.lastName} erneut senden`}
|
||||
className="mt-1 block"
|
||||
>
|
||||
Einladung erneut senden
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ed.active ? "success" : "secondary"}>
|
||||
{ed.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mr-2"
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/personal?tab=verwaltung&educator=${ed.id}`}
|
||||
>
|
||||
Personalprofil
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setFormError("");
|
||||
setEditingEducator(ed);
|
||||
}}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} bearbeiten`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||||
onClick={() => setEducatorToDelete(ed)}
|
||||
disabled={isPending}
|
||||
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
|
||||
<DialogContent>
|
||||
<form action={handleUpdate}>
|
||||
<Dialog
|
||||
open={!!editingEducator}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isPending) setEditingEducator(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleUpdate(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editingEducator && (
|
||||
<div className="grid gap-4 py-4">
|
||||
<fieldset disabled={isPending} className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-firstName">Vorname</Label>
|
||||
<Input id="edit-firstName" name="firstName" defaultValue={editingEducator.firstName} required />
|
||||
<Input
|
||||
id="edit-firstName"
|
||||
name="firstName"
|
||||
defaultValue={editingEducator.firstName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-lastName">Nachname</Label>
|
||||
<Input id="edit-lastName" name="lastName" defaultValue={editingEducator.lastName} required />
|
||||
<Input
|
||||
id="edit-lastName"
|
||||
name="lastName"
|
||||
defaultValue={editingEducator.lastName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<AccessFields
|
||||
prefix="edit"
|
||||
today={today}
|
||||
educator={editingEducator}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
|
||||
<Switch
|
||||
id="edit-active"
|
||||
name="active"
|
||||
defaultChecked={editingEducator.active}
|
||||
/>
|
||||
<Label htmlFor="edit-active">Aktiv</Label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setEditingEducator(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? "Wird gespeichert …"
|
||||
: editingEducator?.email
|
||||
? "Aktualisieren"
|
||||
: "Speichern und einladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,74 +6,23 @@ import { z } from "zod";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { staffAction } from "../personal/actions";
|
||||
|
||||
const educatorSchema = z.object({
|
||||
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
|
||||
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// The staff service creates the person, profile, account and mail job in one transaction.
|
||||
export async function createEducator(rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.educator.create({
|
||||
data: {
|
||||
kitaId,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
active: parsed.data.active,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Anlegen:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
|
||||
return staffAction("educatorCreate", parsed.data);
|
||||
}
|
||||
|
||||
export async function updateEducator(id: string, rawPayload: unknown) {
|
||||
const session = await requireRole([UserRole.ADMIN]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
const parsed = educatorSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.educator.update({
|
||||
where: {
|
||||
id,
|
||||
kitaId,
|
||||
},
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
active: parsed.data.active,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Aktualisieren:", error);
|
||||
return { error: "Ein Fehler ist aufgetreten." };
|
||||
}
|
||||
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
|
||||
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
|
||||
return staffAction("educatorUpdate", { ...parsed.data, educatorId: id });
|
||||
}
|
||||
|
||||
export async function resendEducatorInvitation(educatorId: string) {
|
||||
return staffAction("educatorInvite", { educatorId });
|
||||
}
|
||||
|
||||
export async function deleteEducator(id: string) {
|
||||
@@ -82,15 +31,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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UserRole } from "@prisma/client";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { todayKey } from "@/lib/staff/calculations";
|
||||
import { ErzieherList } from "./_components/erzieher-list";
|
||||
|
||||
export const metadata = { title: "ErzieherInnen · Kita-Planer" };
|
||||
@@ -17,6 +18,18 @@ export default async function ErzieherPage() {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
active: true,
|
||||
staffProfile: {
|
||||
select: {
|
||||
id: true,
|
||||
user: { select: { email: true, emailVerifiedAt: true } },
|
||||
emails: {
|
||||
where: { inviteToken: { not: null }, skippedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { failed: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
});
|
||||
@@ -36,8 +49,8 @@ export default async function ErzieherPage() {
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
@@ -51,7 +64,16 @@ export default async function ErzieherPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ErzieherList educators={educators} />
|
||||
<ErzieherList
|
||||
today={todayKey()}
|
||||
educators={educators.map(({ staffProfile, ...educator }) => ({
|
||||
...educator,
|
||||
hasProfile: !!staffProfile,
|
||||
email: staffProfile?.user?.email ?? null,
|
||||
verified: !!staffProfile?.user?.emailVerifiedAt,
|
||||
inviteFailed: !!staffProfile?.emails[0]?.failed,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import type { ComponentProps, FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { RsvpMode, TerminType } from "@prisma/client";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { suggestedRsvpMode } from "@/lib/rsvp";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -28,6 +29,11 @@ type AdminTerminFormState = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
allDay: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: string;
|
||||
participantInfo: string;
|
||||
mitbringselListEnabled: boolean;
|
||||
};
|
||||
|
||||
const initialFormState: AdminTerminFormState = {
|
||||
@@ -37,6 +43,11 @@ const initialFormState: AdminTerminFormState = {
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
allDay: false,
|
||||
requiresRsvp: false,
|
||||
rsvpMode: RsvpMode.FAMILY,
|
||||
rsvpDeadline: "",
|
||||
participantInfo: "",
|
||||
mitbringselListEnabled: false,
|
||||
};
|
||||
|
||||
type AdminTerminModalProps = {
|
||||
@@ -51,6 +62,12 @@ type AdminTerminModalProps = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allDay: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: Date | null;
|
||||
participantInfo: string | null;
|
||||
mitbringselListEnabled: boolean;
|
||||
hasRsvpResponses: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,10 +78,12 @@ export function AdminTerminModal({
|
||||
existingTermin,
|
||||
}: AdminTerminModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<AdminTerminFormState>(initialFormState);
|
||||
const [formState, setFormState] =
|
||||
useState<AdminTerminFormState>(initialFormState);
|
||||
const [modeTouched, setModeTouched] = useState(false);
|
||||
const modeLocked = existingTermin?.hasRsvpResponses ?? false;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const minDateTime = useMemo(() => getLocalDateTimeInputValue(new Date()), []);
|
||||
const minEndDateTime = formState.startDate || minDateTime;
|
||||
|
||||
// Initialize and reset form fields when modal opens
|
||||
useEffect(() => {
|
||||
@@ -78,10 +97,18 @@ export function AdminTerminModal({
|
||||
startDate: getLocalDateTimeInputValue(existingTermin.startDate),
|
||||
endDate: getLocalDateTimeInputValue(existingTermin.endDate),
|
||||
allDay: existingTermin.allDay,
|
||||
requiresRsvp: existingTermin.requiresRsvp,
|
||||
rsvpMode: existingTermin.rsvpMode,
|
||||
rsvpDeadline: existingTermin.rsvpDeadline
|
||||
? getLocalDateTimeInputValue(existingTermin.rsvpDeadline)
|
||||
: "",
|
||||
participantInfo: existingTermin.participantInfo ?? "",
|
||||
mitbringselListEnabled: existingTermin.mitbringselListEnabled,
|
||||
});
|
||||
} else {
|
||||
setFormState(initialFormState);
|
||||
}
|
||||
setModeTouched(false);
|
||||
}
|
||||
}, [open, existingTermin]);
|
||||
|
||||
@@ -91,7 +118,14 @@ export function AdminTerminModal({
|
||||
) => {
|
||||
setFormState((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === "startDate" && typeof value === "string" && next.endDate && next.endDate < value) {
|
||||
if (key === "type" && !existingTermin && !modeTouched && !modeLocked)
|
||||
next.rsvpMode = suggestedRsvpMode(value as TerminType);
|
||||
if (
|
||||
key === "startDate" &&
|
||||
typeof value === "string" &&
|
||||
next.endDate &&
|
||||
next.endDate < value
|
||||
) {
|
||||
next.endDate = value;
|
||||
}
|
||||
return next;
|
||||
@@ -122,6 +156,13 @@ export function AdminTerminModal({
|
||||
startDate: new Date(formState.startDate).toISOString(),
|
||||
endDate: new Date(formState.endDate).toISOString(),
|
||||
allDay: formState.allDay,
|
||||
requiresRsvp: formState.requiresRsvp,
|
||||
rsvpMode: formState.rsvpMode,
|
||||
rsvpDeadline: formState.rsvpDeadline
|
||||
? new Date(formState.rsvpDeadline).toISOString()
|
||||
: null,
|
||||
participantInfo: formState.participantInfo,
|
||||
mitbringselListEnabled: formState.mitbringselListEnabled,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -138,7 +179,7 @@ export function AdminTerminModal({
|
||||
toast.success(
|
||||
existingTermin
|
||||
? "Termin wurde erfolgreich aktualisiert!"
|
||||
: "Termin wurde direkt angelegt!"
|
||||
: "Termin wurde direkt angelegt!",
|
||||
);
|
||||
if (!existingTermin) {
|
||||
setFormState(initialFormState);
|
||||
@@ -156,11 +197,13 @@ export function AdminTerminModal({
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{existingTermin ? "Termin bearbeiten (Admin)" : "Termin anlegen (Admin)"}
|
||||
{existingTermin
|
||||
? "Termin bearbeiten (Admin)"
|
||||
: "Termin anlegen (Admin)"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{existingTermin
|
||||
@@ -189,7 +232,9 @@ export function AdminTerminModal({
|
||||
name="description"
|
||||
placeholder="Details zum Termin..."
|
||||
value={formState.description}
|
||||
onChange={(event) => updateField("description", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateField("description", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -200,18 +245,28 @@ export function AdminTerminModal({
|
||||
name="type"
|
||||
value={formState.type}
|
||||
className="flex h-10 w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-[13.5px] text-[var(--ink)] ring-offset-[var(--surface)] transition-colors hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
onChange={(event) => updateField("type", event.target.value as TerminType)}
|
||||
onChange={(event) =>
|
||||
updateField("type", event.target.value as TerminType)
|
||||
}
|
||||
required
|
||||
>
|
||||
<option value={TerminType.KITA_FEST}>Kita-Fest</option>
|
||||
<option value={TerminType.SCHLIESSTAG}>Schließtag</option>
|
||||
<option value={TerminType.TEAMTAG}>Teamtag (Kita geschlossen)</option>
|
||||
<option value={TerminType.TEAMTAG}>
|
||||
Teamtag (Kita geschlossen)
|
||||
</option>
|
||||
<option value={TerminType.MITMACH_TAG}>Mitmach-Tag</option>
|
||||
<option value={TerminType.ELTERNABEND}>Elternabend</option>
|
||||
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>Mitgliederversammlung</option>
|
||||
<option value={TerminType.MITGLIEDERVERSAMMLUNG}>
|
||||
Mitgliederversammlung
|
||||
</option>
|
||||
<option value={TerminType.ELTERNCAFE}>Elterncafe</option>
|
||||
<option value={TerminType.GEBURTSTAG_INTERN}>Geburtstag (Intern)</option>
|
||||
<option value={TerminType.GEBURTSTAG_EXTERN}>Raumanfrage (Extern)</option>
|
||||
<option value={TerminType.GEBURTSTAG_INTERN}>
|
||||
Geburtstag (Intern)
|
||||
</option>
|
||||
<option value={TerminType.GEBURTSTAG_EXTERN}>
|
||||
Raumanfrage (Extern)
|
||||
</option>
|
||||
<option value={TerminType.SONSTIGES}>Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -225,7 +280,9 @@ export function AdminTerminModal({
|
||||
type="datetime-local"
|
||||
min={existingTermin ? undefined : minDateTime}
|
||||
value={formState.startDate}
|
||||
onChange={(event) => updateField("startDate", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateField("startDate", event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -235,14 +292,82 @@ export function AdminTerminModal({
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="datetime-local"
|
||||
min={formState.startDate || (existingTermin ? undefined : minDateTime)}
|
||||
min={
|
||||
formState.startDate ||
|
||||
(existingTermin ? undefined : minDateTime)
|
||||
}
|
||||
value={formState.endDate}
|
||||
onChange={(event) => updateField("endDate", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateField("endDate", event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.mitbringselListEnabled}
|
||||
onChange={(e) =>
|
||||
updateField("mitbringselListEnabled", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Mitbring-Liste aktiv
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.requiresRsvp}
|
||||
onChange={(e) => updateField("requiresRsvp", e.target.checked)}
|
||||
/>
|
||||
Teilnahme-Abfrage aktivieren
|
||||
</label>
|
||||
{formState.requiresRsvp ? (
|
||||
<div className="grid gap-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4">
|
||||
<Label htmlFor="calendar-rsvp-mode">Antwortart</Label>
|
||||
<select
|
||||
id="calendar-rsvp-mode"
|
||||
value={formState.rsvpMode}
|
||||
disabled={modeLocked}
|
||||
onChange={(e) => {
|
||||
updateField("rsvpMode", e.target.value as RsvpMode);
|
||||
setModeTouched(true);
|
||||
}}
|
||||
className="h-10 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 text-sm"
|
||||
>
|
||||
<option value="FAMILY">
|
||||
Pro Familie · mit Anzahl Erwachsener
|
||||
</option>
|
||||
<option value="CHILD">Pro Kind · einzelne Zu-/Absage</option>
|
||||
</select>
|
||||
{modeLocked ? (
|
||||
<p className="text-xs">
|
||||
Die Antwortart ist wegen vorhandener Rückmeldungen gesperrt.
|
||||
</p>
|
||||
) : null}
|
||||
<Label htmlFor="calendar-rsvp-deadline">
|
||||
Rückmeldefrist (sonst Terminbeginn)
|
||||
</Label>
|
||||
<Input
|
||||
id="calendar-rsvp-deadline"
|
||||
type="datetime-local"
|
||||
value={formState.rsvpDeadline}
|
||||
onChange={(e) => updateField("rsvpDeadline", e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="calendar-participant-info">
|
||||
Hinweise für Teilnehmer
|
||||
</Label>
|
||||
<Textarea
|
||||
id="calendar-participant-info"
|
||||
value={formState.participantInfo}
|
||||
onChange={(e) =>
|
||||
updateField("participantInfo", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -250,7 +375,9 @@ export function AdminTerminModal({
|
||||
name="allDay"
|
||||
checked={formState.allDay}
|
||||
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||
onChange={(event) => updateField("allDay", event.target.checked)}
|
||||
onChange={(event) =>
|
||||
updateField("allDay", event.target.checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allDay" className="font-normal cursor-pointer">
|
||||
Ganztägiger Termin
|
||||
@@ -259,11 +386,19 @@ export function AdminTerminModal({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Speichern..." : existingTermin ? "Änderungen speichern" : "Termin erstellen"}
|
||||
{isPending
|
||||
? "Speichern..."
|
||||
: existingTermin
|
||||
? "Änderungen speichern"
|
||||
: "Termin erstellen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
WashingMachine,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { RsvpDialog, RsvpForm } from "@/components/rsvp-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -522,6 +523,11 @@ export function CalendarOverviewClient({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.rsvpEvent ? <section className="mt-4 border-t border-[var(--hairline)] pt-4">
|
||||
<h5 className="mb-3 font-medium">Eure Rückmeldung</h5>
|
||||
<RsvpForm key={JSON.stringify(item.rsvpEvent)} event={item.rsvpEvent} />
|
||||
</section> : null}
|
||||
|
||||
{item.participantInfo && item.showParticipantInfo && (
|
||||
<div className="mt-3 rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-3 text-xs text-[var(--ink)]">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 font-medium">
|
||||
@@ -999,6 +1005,8 @@ function AgendaView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.kind === "termin" && item.rsvpEvent ? <RsvpDialog event={item.rsvpEvent} /> : null}
|
||||
|
||||
{/* Actions for admins */}
|
||||
{isAdmin && item.kind === "termin" && (
|
||||
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { DutyAssignmentStatus, TerminStatus, TerminType } from "@prisma/client";
|
||||
import {
|
||||
DutyAssignmentStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
} from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import {
|
||||
@@ -15,6 +20,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { RsvpForm } from "@/components/rsvp-form";
|
||||
import type { RsvpEventDto } from "@/lib/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -44,6 +51,10 @@ export type TerminListItemDto = {
|
||||
allDay: boolean;
|
||||
mitbringselListEnabled: boolean;
|
||||
requiresRsvp: boolean;
|
||||
rsvpMode: RsvpMode;
|
||||
rsvpDeadline: Date | null;
|
||||
hasRsvpResponses: boolean;
|
||||
rsvpEvent?: RsvpEventDto;
|
||||
participantInfo: string | null;
|
||||
showParticipantInfo: boolean;
|
||||
mitbringselItems?: {
|
||||
@@ -88,7 +99,10 @@ export type NotdienstSwapOptionDto = {
|
||||
familyName: string;
|
||||
};
|
||||
|
||||
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
|
||||
export type CalendarListItemDto =
|
||||
| TerminListItemDto
|
||||
| DutyCalendarItemDto
|
||||
| NotdienstCalendarItemDto;
|
||||
|
||||
export function TerminList({
|
||||
termine,
|
||||
@@ -116,7 +130,10 @@ export function TerminList({
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
{isAdmin ? (
|
||||
<AdminTerminModal triggerLabel="+ Termin anlegen" triggerClassName="" />
|
||||
<AdminTerminModal
|
||||
triggerLabel="+ Termin anlegen"
|
||||
triggerClassName=""
|
||||
/>
|
||||
) : (
|
||||
<TerminRequestModal
|
||||
triggerLabel="+ Termin anlegen"
|
||||
@@ -141,14 +158,19 @@ export function TerminList({
|
||||
{group.label}
|
||||
</h2>
|
||||
<span className="rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)] tabular-nums">
|
||||
{group.items.length} {group.items.length === 1 ? "Termin" : "Termine"}
|
||||
{group.items.length}{" "}
|
||||
{group.items.length === 1 ? "Termin" : "Termine"}
|
||||
</span>
|
||||
</div>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||
{group.items.map((item) =>
|
||||
item.kind === "duty" ? (
|
||||
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
|
||||
<DutyRow
|
||||
key={`duty-${item.id}`}
|
||||
duty={item}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : item.kind === "notdienst" ? (
|
||||
<NotdienstRow
|
||||
key={`notdienst-${item.id}`}
|
||||
@@ -157,7 +179,12 @@ export function TerminList({
|
||||
notdienstSwapOptions={notdienstSwapOptions}
|
||||
/>
|
||||
) : (
|
||||
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
|
||||
<TerminRow
|
||||
key={`termin-${item.id}`}
|
||||
termin={item}
|
||||
userId={userId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -168,13 +195,22 @@ export function TerminList({
|
||||
);
|
||||
}
|
||||
|
||||
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
|
||||
function DutyRow({
|
||||
duty,
|
||||
isAdmin,
|
||||
}: {
|
||||
duty: DutyCalendarItemDto;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={duty.startDate} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
@@ -207,7 +243,10 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<a
|
||||
href="/dashboard/admin/dienste"
|
||||
@@ -257,7 +296,10 @@ function TerminRow({
|
||||
<>
|
||||
<Dialog>
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={termin.startDate} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -276,7 +318,9 @@ function TerminRow({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
||||
<CategoryPill color={color}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</CategoryPill>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
Details
|
||||
@@ -297,7 +341,10 @@ function TerminRow({
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<AdminTerminModal
|
||||
existingTermin={termin}
|
||||
@@ -342,7 +389,9 @@ function TerminRow({
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex">
|
||||
<CategoryPill color={color}>{getTypeLabel(termin.type)}</CategoryPill>
|
||||
<CategoryPill color={color}>
|
||||
{getTypeLabel(termin.type)}
|
||||
</CategoryPill>
|
||||
</div>
|
||||
|
||||
{termin.description ? (
|
||||
@@ -355,6 +404,16 @@ function TerminRow({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{termin.rsvpEvent ? (
|
||||
<section className="rounded-lg border border-[var(--hairline)] p-4">
|
||||
<h3 className="mb-3 font-medium">Eure Rückmeldung</h3>
|
||||
<RsvpForm
|
||||
key={JSON.stringify(termin.rsvpEvent)}
|
||||
event={termin.rsvpEvent}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{termin.participantInfo && termin.showParticipantInfo && (
|
||||
<div className="rounded-lg border border-[var(--warn)] bg-[var(--warn-soft)] p-4 text-sm text-[var(--ink)]">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||
@@ -385,8 +444,9 @@ function TerminRow({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin unwiderruflich löschen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Termin “{termin.title}” wirklich löschen? Alle
|
||||
zugehörigen Teilnahmen und Mitbringsel-Einträge gehen verloren.
|
||||
Möchtest du den Termin “{termin.title}” wirklich
|
||||
löschen? Alle zugehörigen Teilnahmen und Mitbringsel-Einträge
|
||||
gehen verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -436,14 +496,20 @@ function CategoryPill({
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-[var(--ink-soft)]"
|
||||
style={{ color }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByMonth(items: CalendarListItemDto[]) {
|
||||
const groups = new Map<string, { key: string; label: string; items: CalendarListItemDto[] }>();
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ key: string; label: string; items: CalendarListItemDto[] }
|
||||
>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const key = format(item.startDate, "yyyy-MM");
|
||||
@@ -559,7 +625,8 @@ export function NotdienstSwapDialog({
|
||||
<DialogTitle>Notdienst tauschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Frage Familie {target.familyName}, ob ihr den Notdienst am{" "}
|
||||
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen könnt.
|
||||
{format(target.startDate, "dd.MM.yyyy", { locale: de })} tauschen
|
||||
könnt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -575,11 +642,14 @@ export function NotdienstSwapDialog({
|
||||
onChange={(event) => setOfferedAssignmentId(event.target.value)}
|
||||
>
|
||||
{selectableOptions.length === 0 ? (
|
||||
<option value="">Kein eigener zukünftiger Notdienst verfügbar</option>
|
||||
<option value="">
|
||||
Kein eigener zukünftiger Notdienst verfügbar
|
||||
</option>
|
||||
) : (
|
||||
selectableOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{format(option.date, "dd.MM.yyyy", { locale: de })} - {option.childName}
|
||||
{format(option.date, "dd.MM.yyyy", { locale: de })} -{" "}
|
||||
{option.childName}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
@@ -603,7 +673,11 @@ export function NotdienstSwapDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isPending}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
@@ -632,7 +706,10 @@ function NotdienstRow({
|
||||
|
||||
return (
|
||||
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||
<div
|
||||
className="absolute inset-y-3 left-0 w-[3px] rounded-r-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<DateTile date={notdienst.startDate} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||
@@ -640,7 +717,8 @@ function NotdienstRow({
|
||||
</h3>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
|
||||
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })}{" "}
|
||||
(ganztägig)
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
|
||||
Kind: {notdienst.childName}
|
||||
@@ -666,7 +744,10 @@ function NotdienstRow({
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||
<a
|
||||
href={`/dashboard/notdienst/plan?year=${notdienst.planYear}&month=${notdienst.planMonth}`}
|
||||
@@ -684,7 +765,11 @@ function NotdienstRow({
|
||||
target={notdienst}
|
||||
options={notdienstSwapOptions}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="h-8 px-2.5 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2.5 text-xs"
|
||||
>
|
||||
Tausch anfragen
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NotdienstAlertStatus,
|
||||
NotdienstPlanStatus,
|
||||
NotdienstSwapStatus,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
UserRole,
|
||||
@@ -22,6 +23,7 @@ import { sendAppEmail } from "@/lib/mail";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { lockEvent } from "@/lib/event-lock";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/kalender · Server Actions
|
||||
@@ -34,8 +36,9 @@ const terminSchema = z.object({
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
allDay: z.boolean().default(false),
|
||||
mitbringselListEnabled: z.boolean().default(false),
|
||||
requiresRsvp: z.boolean().default(false),
|
||||
mitbringselListEnabled: z.boolean().optional(),
|
||||
requiresRsvp: z.boolean().optional(),
|
||||
rsvpMode: z.nativeEnum(RsvpMode).optional(),
|
||||
rsvpDeadline: z.string().datetime().nullable().optional(),
|
||||
participantInfo: z.string().trim().max(1000).optional(),
|
||||
});
|
||||
@@ -55,11 +58,15 @@ const activeNotdienstAlertStatuses = [
|
||||
|
||||
function getTodayUtcDateOnly() {
|
||||
const today = new Date();
|
||||
return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
||||
return new Date(
|
||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
);
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
return process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
return (
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"
|
||||
);
|
||||
}
|
||||
|
||||
function formatNotdienstDate(date: Date) {
|
||||
@@ -72,7 +79,7 @@ function revalidateCalendarAndNotdienst() {
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
revalidatePath("/dashboard");
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore if called outside Next.js context
|
||||
}
|
||||
}
|
||||
@@ -98,6 +105,7 @@ type SwapAssignmentWithFamily = {
|
||||
alerts: {
|
||||
id: string;
|
||||
}[];
|
||||
alarms?: { id: string }[];
|
||||
};
|
||||
|
||||
async function getSwapAssignment(
|
||||
@@ -131,7 +139,11 @@ async function getSwapAssignment(
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
where: { status: { in: activeNotdienstAlertStatuses } },
|
||||
where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } },
|
||||
select: { id: true },
|
||||
},
|
||||
alarms: {
|
||||
where: { activeAssignmentId: { not: null } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
@@ -176,7 +188,12 @@ function validateSwapAssignmentPair({
|
||||
return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
|
||||
}
|
||||
|
||||
if (targetAssignment.alerts.length > 0 || offeredAssignment.alerts.length > 0) {
|
||||
if (
|
||||
targetAssignment.alerts.length > 0 ||
|
||||
offeredAssignment.alerts.length > 0 ||
|
||||
targetAssignment.alarms?.length ||
|
||||
offeredAssignment.alarms?.length
|
||||
) {
|
||||
return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
|
||||
}
|
||||
|
||||
@@ -220,7 +237,10 @@ async function sendSwapMail({
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error);
|
||||
console.error(
|
||||
"Notdienst-Tausch-Mail konnte nicht versendet werden:",
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,20 +282,35 @@ async function notifyFamilyAboutSwap({
|
||||
);
|
||||
}
|
||||
|
||||
function buildTerminData(data: z.infer<typeof terminSchema>) {
|
||||
function buildTerminData(
|
||||
data: z.infer<typeof terminSchema>,
|
||||
allowPast = false,
|
||||
) {
|
||||
const startDate = new Date(data.startDate);
|
||||
const endDate = new Date(data.endDate);
|
||||
const now = new Date();
|
||||
now.setSeconds(0, 0);
|
||||
|
||||
if (startDate < now) {
|
||||
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
|
||||
if (!allowPast && startDate < now) {
|
||||
return {
|
||||
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
|
||||
};
|
||||
}
|
||||
|
||||
if (endDate < startDate) {
|
||||
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
|
||||
}
|
||||
|
||||
if (
|
||||
data.requiresRsvp &&
|
||||
data.rsvpDeadline &&
|
||||
new Date(data.rsvpDeadline) > startDate
|
||||
) {
|
||||
return {
|
||||
error: "Die Rückmeldefrist darf nicht nach dem Terminbeginn liegen.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
title: data.title,
|
||||
@@ -284,16 +319,15 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
|
||||
startDate,
|
||||
endDate,
|
||||
allDay: data.allDay,
|
||||
mitbringselListEnabled: data.mitbringselListEnabled,
|
||||
requiresRsvp: data.requiresRsvp,
|
||||
mitbringselListEnabled: data.mitbringselListEnabled ?? false,
|
||||
requiresRsvp: data.requiresRsvp ?? false,
|
||||
rsvpMode: data.rsvpMode ?? RsvpMode.CHILD,
|
||||
rsvpDeadline:
|
||||
data.requiresRsvp && data.rsvpDeadline
|
||||
? new Date(data.rsvpDeadline)
|
||||
: null,
|
||||
participantInfo:
|
||||
data.requiresRsvp && data.participantInfo
|
||||
? data.participantInfo
|
||||
: null,
|
||||
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -357,6 +391,8 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.");
|
||||
}
|
||||
|
||||
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [targetAssignment.id, offeredAssignment.id] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
|
||||
|
||||
await tx.notdienstSwapRequest.create({
|
||||
data: {
|
||||
kitaId: session.user.kitaId,
|
||||
@@ -388,7 +424,7 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) {
|
||||
console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error);
|
||||
}
|
||||
return { error: "Tauschanfrage konnte nicht erstellt werden." };
|
||||
return { error: publicActionError(error, "Tauschanfrage konnte nicht erstellt werden.") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +527,8 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
throw new UserFacingError(validationError);
|
||||
}
|
||||
|
||||
if (await tx.notdienstAlarm.count({ where: { kitaId: session.user.kitaId, activeAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } } })) throw new UserFacingError("Notdienste mit aktiver Alarmierung können nicht getauscht werden.");
|
||||
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: request.targetAssignmentId },
|
||||
data: { childId: request.offeredAssignment.childId },
|
||||
@@ -632,7 +670,9 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
|
||||
}
|
||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
|
||||
return {
|
||||
error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.",
|
||||
};
|
||||
}
|
||||
|
||||
const responded = await prisma.notdienstSwapRequest.updateMany({
|
||||
@@ -721,7 +761,10 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
revalidateCalendarAndNotdienst();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error);
|
||||
console.error(
|
||||
"Fehler beim Zurückziehen der Notdienst-Tauschanfrage:",
|
||||
error,
|
||||
);
|
||||
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
|
||||
}
|
||||
}
|
||||
@@ -734,7 +777,7 @@ export async function createTerminRequest(rawPayload: unknown) {
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
const data = parsed.data;
|
||||
const terminData = buildTerminData(data);
|
||||
|
||||
@@ -771,12 +814,12 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
const data = parsed.data;
|
||||
const terminData = buildTerminData(data);
|
||||
|
||||
@@ -798,6 +841,7 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen des Termins:", error);
|
||||
@@ -805,7 +849,10 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
|
||||
export async function updateTerminAdmin(
|
||||
terminId: string,
|
||||
rawPayload: unknown,
|
||||
): Promise<{ error?: string; success?: boolean }> {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
|
||||
@@ -818,19 +865,50 @@ export async function updateTerminAdmin(terminId: string, rawPayload: unknown) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
const terminData = buildTerminData(parsed.data);
|
||||
if ("error" in terminData) {
|
||||
return { error: terminData.error };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.termin.update({
|
||||
where: {
|
||||
id: terminId,
|
||||
kitaId,
|
||||
},
|
||||
data: terminData.data,
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await lockEvent(tx, terminId, kitaId);
|
||||
const existing = await tx.termin.findFirst({
|
||||
where: { id: terminId, kitaId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { participations: true, familyParticipations: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing) return { error: "Termin wurde nicht gefunden." };
|
||||
const data = {
|
||||
...parsed.data,
|
||||
mitbringselListEnabled:
|
||||
parsed.data.mitbringselListEnabled ?? existing.mitbringselListEnabled,
|
||||
requiresRsvp: parsed.data.requiresRsvp ?? existing.requiresRsvp,
|
||||
rsvpMode: parsed.data.rsvpMode ?? existing.rsvpMode,
|
||||
rsvpDeadline:
|
||||
parsed.data.rsvpDeadline === undefined
|
||||
? (existing.rsvpDeadline?.toISOString() ?? null)
|
||||
: parsed.data.rsvpDeadline,
|
||||
participantInfo:
|
||||
parsed.data.participantInfo ?? existing.participantInfo ?? "",
|
||||
};
|
||||
if (
|
||||
data.rsvpMode !== existing.rsvpMode &&
|
||||
existing._count.participations + existing._count.familyParticipations >
|
||||
0
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"Die Antwortart kann nach der ersten Rückmeldung nicht mehr geändert werden.",
|
||||
};
|
||||
}
|
||||
const terminData = buildTerminData(data, true);
|
||||
if ("error" in terminData) return { error: terminData.error };
|
||||
await tx.termin.update({
|
||||
where: { id: terminId, kitaId },
|
||||
data: terminData.data,
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
if (result.error) return result;
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
@@ -864,6 +942,7 @@ export async function approveTermin(terminId: string) {
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Bestätigen des Termins:", error);
|
||||
@@ -894,6 +973,7 @@ export async function rejectTermin(terminId: string, reason?: string) {
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Ablehnen des Termins:", error);
|
||||
@@ -901,7 +981,10 @@ export async function rejectTermin(terminId: string, reason?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
|
||||
export async function toggleMitbringselList(
|
||||
terminId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
@@ -921,6 +1004,7 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean)
|
||||
|
||||
revalidatePath("/dashboard/kalender");
|
||||
revalidatePath("/dashboard/admin/kalender");
|
||||
revalidatePath("/dashboard");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Umschalten der Mitbring-Liste:", error);
|
||||
@@ -941,7 +1025,7 @@ export async function addMitbringsel(rawPayload: unknown) {
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
const { terminId, content } = parsed.data;
|
||||
|
||||
try {
|
||||
@@ -950,7 +1034,11 @@ export async function addMitbringsel(rawPayload: unknown) {
|
||||
where: { id: terminId, kitaId: session.user.kitaId },
|
||||
});
|
||||
|
||||
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
|
||||
if (
|
||||
!termin ||
|
||||
termin.status !== TerminStatus.CONFIRMED ||
|
||||
!termin.mitbringselListEnabled
|
||||
) {
|
||||
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,16 @@ import { HelpCircle } from "lucide-react";
|
||||
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||
import { AdminTerminModal } from "./_components/admin-termin-modal";
|
||||
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
|
||||
import { type CalendarListItemDto, type NotdienstSwapOptionDto } from "./_components/termin-list";
|
||||
import {
|
||||
PendingAnfragen,
|
||||
type PendingTerminDto,
|
||||
} from "./_components/pending-anfragen";
|
||||
import {
|
||||
type CalendarListItemDto,
|
||||
type NotdienstSwapOptionDto,
|
||||
} from "./_components/termin-list";
|
||||
import { TerminRequestModal } from "./_components/termin-request-modal";
|
||||
import {
|
||||
CalendarOverviewClient,
|
||||
@@ -36,50 +43,62 @@ export default async function KalenderPage({
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
const confirmedTermineRows = await prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
participantInfo: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
const [rsvpEventRows, confirmedTermineRows] = await Promise.all([
|
||||
getRsvpEvents(session),
|
||||
prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
rsvpDeadline: true,
|
||||
_count: {
|
||||
select: { participations: true, familyParticipations: true },
|
||||
},
|
||||
familyParticipations: {
|
||||
where: { familyId: familyIdForParticipation },
|
||||
select: { status: true },
|
||||
},
|
||||
participantInfo: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
where: {
|
||||
child: {
|
||||
familyId: familyIdForParticipation,
|
||||
participations: {
|
||||
where: {
|
||||
child: {
|
||||
familyId: familyIdForParticipation,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
childId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
childId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const myPendingTermineRows = await prisma.termin.findMany({
|
||||
where: {
|
||||
@@ -98,6 +117,13 @@ export default async function KalenderPage({
|
||||
allDay: true,
|
||||
mitbringselListEnabled: true,
|
||||
requiresRsvp: true,
|
||||
rsvpMode: true,
|
||||
rsvpDeadline: true,
|
||||
_count: { select: { participations: true, familyParticipations: true } },
|
||||
familyParticipations: {
|
||||
where: { familyId: familyIdForParticipation },
|
||||
select: { status: true },
|
||||
},
|
||||
participantInfo: true,
|
||||
mitbringselItems: {
|
||||
select: {
|
||||
@@ -236,31 +262,32 @@ export default async function KalenderPage({
|
||||
})
|
||||
: [];
|
||||
|
||||
const rsvpEvents = new Map(rsvpEventRows.map((event) => [event.id, event]));
|
||||
const mapTermin = (
|
||||
termin: (typeof confirmedTermineRows)[number],
|
||||
): CalendarListItemDto => {
|
||||
const showParticipantInfo =
|
||||
isAdmin ||
|
||||
termin.familyParticipations.some(
|
||||
(row) => row.status === EventParticipationStatus.ATTENDING,
|
||||
) ||
|
||||
termin.participations.some(
|
||||
(row) => row.status === EventParticipationStatus.ATTENDING,
|
||||
);
|
||||
const { _count, familyParticipations, ...data } = termin;
|
||||
void familyParticipations;
|
||||
return {
|
||||
...data,
|
||||
kind: "termin",
|
||||
showParticipantInfo,
|
||||
participantInfo: showParticipantInfo ? termin.participantInfo : null,
|
||||
hasRsvpResponses: _count.participations + _count.familyParticipations > 0,
|
||||
rsvpEvent: rsvpEvents.get(termin.id),
|
||||
};
|
||||
};
|
||||
const allUserTermine: CalendarListItemDto[] = [
|
||||
...confirmedTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
|
||||
? termin.participantInfo : null,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
termin.participations.some(
|
||||
(participation) =>
|
||||
participation.status === EventParticipationStatus.ATTENDING,
|
||||
),
|
||||
})),
|
||||
...myPendingTermineRows.map((termin) => ({
|
||||
...termin,
|
||||
participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING)
|
||||
? termin.participantInfo : null,
|
||||
kind: "termin" as const,
|
||||
showParticipantInfo:
|
||||
isAdmin ||
|
||||
termin.participations.some(
|
||||
(participation) =>
|
||||
participation.status === EventParticipationStatus.ATTENDING,
|
||||
),
|
||||
})),
|
||||
...confirmedTermineRows.map(mapTermin),
|
||||
...myPendingTermineRows.map(mapTermin),
|
||||
...dutyAssignmentRows.map((assignment) => {
|
||||
const isOwn = assignment.familyId === familyIdForParticipation;
|
||||
return {
|
||||
@@ -320,7 +347,9 @@ export default async function KalenderPage({
|
||||
(request) => ({
|
||||
id: request.id,
|
||||
direction:
|
||||
request.requesterFamilyId === session.user.familyId ? "outgoing" : "incoming",
|
||||
request.requesterFamilyId === session.user.familyId
|
||||
? "outgoing"
|
||||
: "incoming",
|
||||
targetDate: request.targetAssignment.date,
|
||||
targetFamilyName: request.targetAssignment.child.family.name,
|
||||
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
|
||||
@@ -371,9 +400,7 @@ export default async function KalenderPage({
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
||||
Was steht{" "}
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">
|
||||
an
|
||||
</em>
|
||||
<em className="font-normal italic text-[var(--accent-deep)]">an</em>
|
||||
?
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
@@ -437,8 +464,6 @@ export default async function KalenderPage({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function TabBar({
|
||||
currentTab,
|
||||
overviewCount,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getAnnouncements } from "@/lib/announcements";
|
||||
import { NewsTicker } from "../news-ticker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const metadata = { title: "Mitteilungen · Kita-Planer" };
|
||||
|
||||
export default async function MitteilungenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const session = await requireKitaSession();
|
||||
const params = await searchParams;
|
||||
const parsed = Number(params.page ?? 1);
|
||||
const page =
|
||||
Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, 100000) : 1;
|
||||
const pageSize = 12;
|
||||
const items = await getAnnouncements(session, {
|
||||
take: pageSize + 1,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||
SCHWARZES BRETT
|
||||
</p>
|
||||
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
|
||||
Alle{" "}
|
||||
<em className="font-normal text-[var(--accent-deep)]">
|
||||
Mitteilungen
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] text-[var(--ink-soft)]">
|
||||
Aktuelle und ältere Meldungen für deine Empfängergruppe.
|
||||
</p>
|
||||
</header>
|
||||
<NewsTicker items={items.slice(0, pageSize)} archive />
|
||||
<nav
|
||||
aria-label="Mitteilungen durchblättern"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
{page > 1 ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/dashboard/mitteilungen?page=${page - 1}`}>
|
||||
Neuere Meldungen
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--ink-soft)]">Seite {page}</span>
|
||||
<div>
|
||||
{items.length > pageSize ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/dashboard/mitteilungen?page=${page + 1}`}>
|
||||
Ältere Meldungen
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Bell, FileText, Megaphone } from "lucide-react";
|
||||
|
||||
@@ -26,6 +28,7 @@ export type NewsTickerItemDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
createdAt: string;
|
||||
authorName: string;
|
||||
isUnread: boolean;
|
||||
@@ -37,22 +40,32 @@ export type NewsTickerItemDto = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
export function NewsTicker({
|
||||
items,
|
||||
archive = false,
|
||||
}: {
|
||||
items: NewsTickerItemDto[];
|
||||
archive?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<NewsTickerItemDto | null>(null);
|
||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function openAnnouncement(item: NewsTickerItemDto) {
|
||||
setSelected(item);
|
||||
if (!item.isUnread || readIds.has(item.id)) return;
|
||||
|
||||
setReadIds((current) => new Set(current).add(item.id));
|
||||
startTransition(async () => {
|
||||
await markAnnouncementRead(item.id);
|
||||
try {
|
||||
const result = await markAnnouncementRead(item.id);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setReadIds((current) => new Set(current).add(item.id));
|
||||
} catch {
|
||||
toast.error("Lesestatus konnte nicht gespeichert werden.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,20 +73,33 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
<>
|
||||
<Card className="mb-8 border-[var(--good)] bg-[var(--good-soft)]">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Megaphone className="h-5 w-5 text-[var(--good)] [stroke-width:1.7]" />
|
||||
Schwarzes Brett
|
||||
{archive ? "Mitteilungen" : "Neues aus der Kita"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Die neuesten offiziellen Ankündigungen deiner Kita.
|
||||
{archive
|
||||
? "Veröffentlichte Ankündigungen deiner Kita."
|
||||
: "Die neuesten offiziellen Ankündigungen deiner Kita."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{items.length}</Badge>
|
||||
{archive ? (
|
||||
<Badge variant="secondary">{items.length} Meldungen</Badge>
|
||||
) : (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/dashboard/mitteilungen">Alle Mitteilungen</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="col-span-full py-4 text-sm text-[var(--ink-soft)]">
|
||||
Noch keine Mitteilungen für deine Empfängergruppe.
|
||||
</p>
|
||||
) : null}
|
||||
{items.map((item) => {
|
||||
const isUnread = item.isUnread && !readIds.has(item.id);
|
||||
return (
|
||||
@@ -88,15 +114,13 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
<Bell className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{item.createdAt}
|
||||
</div>
|
||||
{isUnread && (
|
||||
<Badge variant="success">NEU</Badge>
|
||||
)}
|
||||
{isUnread && <Badge variant="success">NEU</Badge>}
|
||||
</div>
|
||||
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{item.content}
|
||||
{item.excerpt}
|
||||
</p>
|
||||
{item.attachments.length > 0 && (
|
||||
<p className="mt-3 flex items-center gap-1 text-xs font-medium text-[var(--accent-deep)]">
|
||||
@@ -111,7 +135,10 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<Dialog
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
{selected && (
|
||||
<>
|
||||
@@ -137,7 +164,11 @@ export function NewsTicker({ items }: { items: NewsTickerItemDto[] }) {
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href={attachment.fileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<FileText className="h-4 w-4 [stroke-width:1.7]" />
|
||||
{attachment.fileName}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
import { publicActionError } from "@/lib/action-error";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { executeAlarmCommand } from "@/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||
|
||||
export type AlarmActionResult = {
|
||||
error?: string;
|
||||
warning?: string;
|
||||
success?: boolean;
|
||||
alarmId?: string;
|
||||
};
|
||||
export async function alarmAction(
|
||||
command: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<AlarmActionResult> {
|
||||
const actor = await requireAlarmActor(command !== "readNotice");
|
||||
try {
|
||||
const result = await executeAlarmCommand(actor, command, data);
|
||||
let warning: string | undefined;
|
||||
if (result.emailIds.length) {
|
||||
try {
|
||||
const delivery = await deliverAlarmEmails(result.emailIds);
|
||||
if (delivery.failed)
|
||||
warning =
|
||||
"Gespeichert. Einige E-Mails warten noch auf den Versand. Bitte den Versandstand im Alarm prüfen.";
|
||||
else if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.RESEND_API_KEY
|
||||
)
|
||||
warning = "Gespeichert. E-Mail-Versand lokal simuliert.";
|
||||
} catch {
|
||||
warning = "Gespeichert. Der E-Mail-Versand ist noch ausstehend.";
|
||||
}
|
||||
}
|
||||
for (const path of [
|
||||
"/dashboard",
|
||||
"/dashboard/notdienst/alarm",
|
||||
"/dashboard/notdienst/plan",
|
||||
"/dashboard/kalender",
|
||||
])
|
||||
revalidatePath(path);
|
||||
revalidatePath("/alert/[token]", "page");
|
||||
return { success: true, alarmId: result.alarmId, warning };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return {
|
||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
};
|
||||
const message = publicActionError(error, "");
|
||||
if (message) return { error: message };
|
||||
return { error: "Die Alarmaktion konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getAlarmFeed, managerRoles } from "@/lib/alarms/service";
|
||||
import {
|
||||
alarmLabel,
|
||||
durationLabel,
|
||||
dateKey,
|
||||
needsClarification,
|
||||
} from "@/lib/alarms/format";
|
||||
import { AlarmRefresh } from "./forms";
|
||||
|
||||
export async function AlarmDashboardHints() {
|
||||
const { user } = await requireKitaSession();
|
||||
if (![...managerRoles, "ELTERN"].includes(user.role)) return null;
|
||||
let alarms;
|
||||
try {
|
||||
alarms = await getAlarmFeed({
|
||||
id: user.id!,
|
||||
kitaId: user.kitaId,
|
||||
role: user.role,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!alarms.length) return null;
|
||||
return (
|
||||
<section className="mb-6 rounded-2xl border border-[var(--accent-mid)] bg-[var(--surface)] p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">Aktuelle Notdienstalarme</h2>
|
||||
<AlarmRefresh />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{alarms.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/dashboard/notdienst/alarm?alarm=${a.id}`}
|
||||
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm ${needsClarification(a) ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||
>
|
||||
<span>
|
||||
{dateKey(a.date).split("-").reverse().join(".")} · {a.familyName}
|
||||
<span className="mt-1 block text-xs text-[var(--ink-soft)]">
|
||||
{durationLabel(a)}
|
||||
</span>
|
||||
</span>
|
||||
<strong>{alarmLabel(a)} →</strong>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
import { useEffect, useId, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { clockMinute } from "@/lib/staff/calculations";
|
||||
import { alarmAction } from "./actions";
|
||||
|
||||
const control =
|
||||
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
|
||||
export function AlarmRefresh() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) router.refresh();
|
||||
}, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [router]);
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => router.refresh()}>
|
||||
Stand aktualisieren
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export type AlarmCandidate = { id: string; label: string; emails: string[] };
|
||||
export function AlarmCreateForm({
|
||||
candidates,
|
||||
educators,
|
||||
selected,
|
||||
}: {
|
||||
candidates: AlarmCandidate[];
|
||||
educators: { id: string; name: string }[];
|
||||
selected?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!!selected);
|
||||
const [assignmentId, setAssignmentId] = useState(
|
||||
candidates.find((c) => c.id === selected)?.id ?? candidates[0]?.id ?? "",
|
||||
);
|
||||
const [wholeDay, setWholeDay] = useState(true);
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const prefix = useId();
|
||||
const candidate = candidates.find((c) => c.id === assignmentId);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!candidates.length}>
|
||||
<BellRing className="mr-2 h-4 w-4" />
|
||||
Notdienstalarm auslösen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notdienstalarm auslösen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die eingeteilte Familie wird per E-Mail und in der App informiert.
|
||||
Eine Antwort gilt für den Haushalt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
const result = await alarmAction("create", {
|
||||
assignmentId,
|
||||
wholeDay,
|
||||
replyMinutes: Number(form.get("replyMinutes")),
|
||||
...(wholeDay
|
||||
? {}
|
||||
: {
|
||||
startMinute: clockMinute(String(form.get("start"))),
|
||||
endMinute: clockMinute(String(form.get("end"))),
|
||||
}),
|
||||
...(form.get("educatorId")
|
||||
? { educatorId: String(form.get("educatorId")) }
|
||||
: {}),
|
||||
notes: String(form.get("notes") ?? ""),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Alarm ausgelöst.");
|
||||
setOpen(false);
|
||||
router.push(`/dashboard/notdienst/alarm?alarm=${result.alarmId}`);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className="grid gap-4">
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-assignment`}
|
||||
>
|
||||
Eingeteilte Familie
|
||||
<select
|
||||
id={`${prefix}-assignment`}
|
||||
className={control}
|
||||
required
|
||||
value={assignmentId}
|
||||
onChange={(event) => setAssignmentId(event.target.value)}
|
||||
>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="rounded-lg bg-[var(--surface-2)] p-3 text-sm">
|
||||
<p className="font-medium">Empfänger</p>
|
||||
{candidate?.emails.length ? (
|
||||
<ul className="mt-1 grid gap-1 break-all">
|
||||
{candidate.emails.map((email) => (
|
||||
<li key={email}>{email}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[var(--danger)]">
|
||||
Keine Elternkonten hinterlegt. Bitte den Haushalt zuerst
|
||||
vervollständigen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-duration`}
|
||||
>
|
||||
Einsatzdauer
|
||||
<select
|
||||
id={`${prefix}-duration`}
|
||||
className={control}
|
||||
value={wholeDay ? "whole" : "hours"}
|
||||
onChange={(event) =>
|
||||
setWholeDay(event.target.value === "whole")
|
||||
}
|
||||
>
|
||||
<option value="whole">Ganzer Kita-Tag</option>
|
||||
<option value="hours">Bestimmte Uhrzeiten</option>
|
||||
</select>
|
||||
</label>
|
||||
{!wholeDay && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-start`}
|
||||
>
|
||||
Beginn
|
||||
<Input
|
||||
id={`${prefix}-start`}
|
||||
type="time"
|
||||
name="start"
|
||||
required
|
||||
defaultValue="08:00"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-end`}
|
||||
>
|
||||
Ende
|
||||
<Input
|
||||
id={`${prefix}-end`}
|
||||
type="time"
|
||||
name="end"
|
||||
required
|
||||
defaultValue="16:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-minutes`}
|
||||
>
|
||||
Antwortfrist in Minuten
|
||||
<Input
|
||||
id={`${prefix}-minutes`}
|
||||
name="replyMinutes"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
step={1}
|
||||
defaultValue={30}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Die Frist beginnt beim Auslösen und endet spätestens mit dem
|
||||
Einsatz. Eine Absage erfordert sofort Klärung.
|
||||
</p>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-educator`}
|
||||
>
|
||||
Ausgefallene Fachkraft (optional, intern)
|
||||
<select
|
||||
id={`${prefix}-educator`}
|
||||
className={control}
|
||||
name="educatorId"
|
||||
>
|
||||
<option value="">Ohne Zuordnung</option>
|
||||
{educators.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-notes`}>
|
||||
Interne Notiz (optional)
|
||||
<textarea
|
||||
id={`${prefix}-notes`}
|
||||
className={control}
|
||||
name="notes"
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" disabled={!candidate?.emails.length}>
|
||||
{pending
|
||||
? "Alarm wird gespeichert …"
|
||||
: "Alarm auslösen und Familie informieren"}
|
||||
</Button>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function AlarmCommand({
|
||||
alarmId,
|
||||
command,
|
||||
label,
|
||||
}: {
|
||||
alarmId: string;
|
||||
command: "cancel" | "recordResponse" | "approveLate";
|
||||
label: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false),
|
||||
[pending, start] = useTransition(),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter(),
|
||||
prefix = useId();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{command === "cancel"
|
||||
? "Alle angeschriebenen Elternkonten erhalten eine Stornierung. Ihre Rückmeldelinks erlauben danach keine weitere Antwort."
|
||||
: "Die Rückmeldung gilt für den gesamten Haushalt. Die Familie und die Koordination werden über den Stand informiert."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
const result = await alarmAction(command, {
|
||||
alarmId,
|
||||
reason: String(data.get("reason")),
|
||||
...(command === "recordResponse"
|
||||
? { approve: data.get("response") === "yes" }
|
||||
: {}),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Gespeichert.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{command === "recordResponse" && (
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-response`}
|
||||
>
|
||||
Telefonische Rückmeldung / Korrektur
|
||||
<select
|
||||
id={`${prefix}-response`}
|
||||
name="response"
|
||||
className={control}
|
||||
>
|
||||
<option value="yes">Haushalt übernimmt</option>
|
||||
<option value="no">Haushalt kann nicht übernehmen</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-reason`}>
|
||||
Interne Begründung
|
||||
<textarea
|
||||
id={`${prefix}-reason`}
|
||||
className={control}
|
||||
name="reason"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Begründung bleibt intern und wird nicht per E-Mail versendet.
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button disabled={pending} type="submit">
|
||||
{pending
|
||||
? "Wird gespeichert …"
|
||||
: command === "cancel"
|
||||
? "Alarm stornieren"
|
||||
: "Rückmeldung speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function AlarmSimpleAction({
|
||||
command,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
command: "retry" | "readNotice";
|
||||
data: Record<string, unknown>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const result = await alarmAction(command, data);
|
||||
if (result.error) toast.error(result.error);
|
||||
else if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Gespeichert.");
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Bitte warten …" : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import Link from "next/link";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { managerRoles, importLegacyAlarms } from "@/lib/alarms/service";
|
||||
import {
|
||||
alarmLabel,
|
||||
dateKey,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
needsClarification,
|
||||
todayKey,
|
||||
} from "@/lib/alarms/format";
|
||||
import { addDays, dateOnly } from "@/lib/staff/calculations";
|
||||
import {
|
||||
AlarmCreateForm,
|
||||
AlarmCommand,
|
||||
AlarmSimpleAction,
|
||||
AlarmRefresh,
|
||||
} from "./forms";
|
||||
|
||||
export const metadata = { title: "Notdienstalarm · Kita-Planer" };
|
||||
const historyLabels: Record<string, string> = {
|
||||
CONFIRMED: "Übernahme bestätigt",
|
||||
DECLINED: "Absage",
|
||||
LATE_CONFIRMATION: "Verspätete Zusage – Freigabe erforderlich",
|
||||
};
|
||||
export default async function AlarmPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ alarm?: string; assignment?: string }>;
|
||||
}) {
|
||||
const actor = await requireAlarmActor(false),
|
||||
manager = managerRoles.includes(actor.role);
|
||||
const params = await searchParams,
|
||||
now = new Date(),
|
||||
today = todayKey(now);
|
||||
if (manager) await importLegacyAlarms(actor.kitaId);
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: actor.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
const [kita, alarms, assignments, educators, notifications] =
|
||||
await Promise.all([
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: actor.kitaId },
|
||||
select: { name: true, notdienstModuleEnabled: true },
|
||||
}),
|
||||
prisma.notdienstAlarm.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||
...(params.alarm
|
||||
? { id: params.alarm }
|
||||
: { date: { gte: dateOnly(addDays(today, -30)) } }),
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
include: { user: { select: { email: true, firstName: true } } },
|
||||
},
|
||||
emails: { orderBy: { createdAt: "desc" } },
|
||||
history: manager
|
||||
? { orderBy: { createdAt: "desc" }, take: 20 }
|
||||
: false,
|
||||
},
|
||||
orderBy: [{ date: "desc" }, { triggeredAt: "desc" }],
|
||||
take: 100,
|
||||
}),
|
||||
manager
|
||||
? prisma.notdienstAssignment.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
date: { gte: dateOnly(today), lt: dateOnly(addDays(today, 93)) },
|
||||
plan: { status: "PUBLISHED" },
|
||||
alarms: { none: { activeAssignmentId: { not: null } } },
|
||||
},
|
||||
include: {
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: "ELTERN", kitaId: actor.kitaId },
|
||||
select: { email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
manager
|
||||
? prisma.educator.findMany({
|
||||
where: { kitaId: actor.kitaId, active: true },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
orderBy: { lastName: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
prisma.notdienstAlarmNotification.findMany({
|
||||
where: {
|
||||
userId: actor.id,
|
||||
alarm: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||
},
|
||||
readAt: null,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
const candidates = assignments.map((a) => ({
|
||||
id: a.id,
|
||||
label: `${dateKey(a.date).split("-").reverse().join(".")} · ${a.child.family.name}`,
|
||||
emails: a.child.family.users.map((u) => u.email),
|
||||
}));
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||
NOTDIENST · {kita.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl tracking-tight text-[var(--ink)]">
|
||||
Notdienst
|
||||
<span className="italic text-[var(--accent-deep)]">alarm</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{manager
|
||||
? "Familien anfragen, Rückmeldungen verfolgen und offene Einsätze klären."
|
||||
: "Eure Rückmeldung gilt für den gesamten Haushalt."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AlarmRefresh />
|
||||
{manager && kita.notdienstModuleEnabled && (
|
||||
<AlarmCreateForm
|
||||
candidates={candidates}
|
||||
educators={educators.map((e) => ({
|
||||
id: e.id,
|
||||
name: `${e.firstName} ${e.lastName}`,
|
||||
}))}
|
||||
selected={params.assignment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{manager &&
|
||||
(!process.env.NOTDIENST_JOB_SECRET ||
|
||||
process.env.NOTDIENST_JOB_SECRET.length < 32) && (
|
||||
<p className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||
Die automatische Fristprüfung ist noch nicht eingerichtet. Für
|
||||
Erinnerungen bei geschlossener App muss die Hosting-Konfiguration
|
||||
ergänzt werden. Fristabläufe sind hier bereits sichtbar.
|
||||
</p>
|
||||
)}
|
||||
{!kita.notdienstModuleEnabled && (
|
||||
<p className="mb-6 text-sm">
|
||||
Das Notdienstmodul ist deaktiviert; vorhandene Alarme bleiben
|
||||
einsehbar.
|
||||
</p>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<details className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold">
|
||||
{notifications.length} neue Hinweise
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3"
|
||||
>
|
||||
<Link
|
||||
className="max-w-3xl text-sm underline decoration-[var(--accent-mid)] underline-offset-4"
|
||||
href={`/dashboard/notdienst/alarm?alarm=${n.alarmId}`}
|
||||
>
|
||||
{n.message}
|
||||
</Link>
|
||||
<AlarmSimpleAction command="readNotice" data={{ id: n.id }}>
|
||||
Gelesen
|
||||
</AlarmSimpleAction>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{params.alarm && (
|
||||
<Link
|
||||
className="mb-5 inline-block text-sm text-[var(--accent-deep)] underline"
|
||||
href="/dashboard/notdienst/alarm"
|
||||
>
|
||||
Alle Alarme anzeigen
|
||||
</Link>
|
||||
)}
|
||||
{manager && !candidates.length && !params.alarm && (
|
||||
<p className="mb-5 text-sm text-[var(--ink-soft)]">
|
||||
Neue Alarme benötigen eine aktuelle oder zukünftige Einteilung in
|
||||
einem veröffentlichten Plan.{" "}
|
||||
<Link href="/dashboard/notdienst/plan" className="underline">
|
||||
Notdienstplan öffnen
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-5">
|
||||
{alarms.length ? (
|
||||
alarms.map((a) => {
|
||||
const clarify = needsClarification(a, now),
|
||||
ended = a.expiresAt <= now;
|
||||
const pending = a.emails.filter((e) => !e.sentAt && !e.skippedAt);
|
||||
const ownRecipient = a.recipients.find(
|
||||
(r) => r.userId === actor.id,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={a.id}
|
||||
id={`alarm-${a.id}`}
|
||||
className={`overflow-hidden rounded-2xl border bg-[var(--surface)] ${clarify ? "border-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-start justify-between gap-4 border-b border-[var(--hairline-soft)] p-5 sm:p-6 ${clarify ? "bg-[var(--surface-2)]" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--ink-soft)]">
|
||||
<BellRing className="h-4 w-4" />
|
||||
{dateKey(a.date) === today ? "Heute · " : ""}
|
||||
{dateKey(a.date).split("-").reverse().join(".")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl">{a.familyName}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
{durationLabel(a)}
|
||||
{ended ? " · Einsatz beendet" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`max-w-full rounded-full border px-3 py-1.5 text-xs font-semibold ${clarify ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"}`}
|
||||
>
|
||||
{alarmLabel(a, now)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-5 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 text-sm">
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
Antwortfrist
|
||||
</span>
|
||||
<br />
|
||||
<strong>{instantLabel(a.deadline)} Uhr</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">Ausgelöst</span>
|
||||
<br />
|
||||
{instantLabel(a.triggeredAt)} Uhr
|
||||
</p>
|
||||
{a.respondedAt && (
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
Rückmeldung
|
||||
</span>
|
||||
<br />
|
||||
{instantLabel(a.respondedAt)} Uhr ·{" "}
|
||||
{a.responseSource === "PHONE"
|
||||
? "Telefonisch dokumentiert"
|
||||
: a.responseSource === "COORDINATION"
|
||||
? "Freigabe durch Koordination"
|
||||
: a.responseSource === "LEGACY"
|
||||
? "Bisherige Alarmierung"
|
||||
: "Haushaltsantwort"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{manager ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{a.status === "LATE_CONFIRMATION" && !ended && (
|
||||
<AlarmCommand
|
||||
command="approveLate"
|
||||
alarmId={a.id}
|
||||
label="Verspätete Zusage freigeben"
|
||||
/>
|
||||
)}
|
||||
{a.status !== "CANCELLED" && !ended && (
|
||||
<AlarmCommand
|
||||
command="recordResponse"
|
||||
alarmId={a.id}
|
||||
label="Telefonische Rückmeldung / Korrektur"
|
||||
/>
|
||||
)}
|
||||
{a.status !== "CANCELLED" && (
|
||||
<AlarmCommand
|
||||
command="cancel"
|
||||
alarmId={a.id}
|
||||
label="Alarm stornieren"
|
||||
/>
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<AlarmSimpleAction
|
||||
command="retry"
|
||||
data={{ alarmId: a.id }}
|
||||
>
|
||||
Ausstehende E-Mails erneut senden ({pending.length})
|
||||
</AlarmSimpleAction>
|
||||
)}
|
||||
</div>
|
||||
{clarify && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-3 text-sm leading-6">
|
||||
Bitte den Haushalt kontaktieren oder Ersatz manuell
|
||||
organisieren. Weitere Familien werden nicht
|
||||
automatisch alarmiert.
|
||||
</p>
|
||||
)}
|
||||
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Versandstand (
|
||||
{a.emails.filter((e) => e.sentAt).length} angenommen,{" "}
|
||||
{pending.length} ausstehend)
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{a.emails.map((e) => (
|
||||
<p key={e.id} className="break-all">
|
||||
{a.recipients.find((r) => r.userId === e.userId)
|
||||
?.user.email ?? "Koordination / Admin"}{" "}
|
||||
·{" "}
|
||||
{e.kind === "REQUEST"
|
||||
? "Anfrage"
|
||||
: e.kind === "CANCEL"
|
||||
? "Stornierung"
|
||||
: "Hinweis"}
|
||||
:{" "}
|
||||
{e.sentAt
|
||||
? "Vom Maildienst angenommen"
|
||||
: e.skippedAt
|
||||
? "Nicht mehr erforderlich / Empfänger geändert"
|
||||
: e.failed
|
||||
? "Versand fehlgeschlagen – wiederholbar"
|
||||
: "Versand ausstehend"}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Interne Details und Verlauf
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{a.educatorId && (
|
||||
<p>
|
||||
Ausgefallene Fachkraft:{" "}
|
||||
{educators.find((e) => e.id === a.educatorId)
|
||||
?.firstName ??
|
||||
"Ehemalige / inaktive Fachkraft"}{" "}
|
||||
{educators.find((e) => e.id === a.educatorId)
|
||||
?.lastName ?? ""}
|
||||
</p>
|
||||
)}
|
||||
{a.notes && <p>Interne Notiz: {a.notes}</p>}
|
||||
{a.cancelReason && (
|
||||
<p>Stornierungsgrund: {a.cancelReason}</p>
|
||||
)}
|
||||
{a.history.map((h) => (
|
||||
<p key={h.id}>
|
||||
{instantLabel(h.createdAt)} · {h.action}:{" "}
|
||||
{historyLabels[h.details] ?? h.details}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ownRecipient && a.status === "PENDING" && !ended && (
|
||||
<Link
|
||||
className="inline-flex w-fit rounded-lg bg-[var(--accent-deep)] px-4 py-2 text-sm font-semibold text-white"
|
||||
href={`/alert/${ownRecipient.token}`}
|
||||
>
|
||||
Für unseren Haushalt antworten
|
||||
</Link>
|
||||
)}
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
{a.status === "LATE_CONFIRMATION"
|
||||
? "Eure verspätete Zusage wartet auf Freigabe durch die Koordination."
|
||||
: a.status === "CONFIRMED"
|
||||
? "Die Kita rechnet mit euch. Änderungen bitte mit der Koordination klären."
|
||||
: a.status === "CANCELLED"
|
||||
? "Ihr müsst für diesen Alarm nicht einspringen."
|
||||
: a.status === "DECLINED"
|
||||
? "Eure Absage ist gespeichert. Die Koordination organisiert Ersatz."
|
||||
: "Eine Antwort eines Elternteils gilt für euren ganzen Haushalt."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] p-8 text-sm text-[var(--ink-soft)]">
|
||||
{params.alarm
|
||||
? "Kein zugänglicher Alarm gefunden."
|
||||
: "Noch keine Notdienstalarme in diesem Zeitraum."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { UserFacingError, publicActionError } from "@/lib/action-error";
|
||||
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { createElement } from "react";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import {
|
||||
validateAlarmActor,
|
||||
assertAssignmentNotAlarmed,
|
||||
} from "@/lib/alarms/service";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/notdienst/plan · Server Actions
|
||||
@@ -158,42 +163,76 @@ export async function updateAssignmentAction(
|
||||
date: string,
|
||||
newChildId: string | null,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
|
||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
try {
|
||||
const notifications = await prisma.$transaction(async (tx) => {
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
});
|
||||
const notifications = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await validateAlarmActor(tx, actor);
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new UserFacingError("Plan nicht gefunden.");
|
||||
}
|
||||
|
||||
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||
|
||||
if (!isDraft && !isPublished) {
|
||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
const today = new Date();
|
||||
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
||||
if (parsedDate < todayUtc) {
|
||||
throw new UserFacingError("Vergangene Notdienste können nicht geändert werden.");
|
||||
if (!plan) {
|
||||
throw new UserFacingError("Plan nicht gefunden.");
|
||||
}
|
||||
}
|
||||
|
||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { planId, date: parsedDate },
|
||||
include: {
|
||||
child: {
|
||||
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||
|
||||
if (!isDraft && !isPublished) {
|
||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
const today = new Date();
|
||||
const todayUtc = new Date(
|
||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
);
|
||||
if (parsedDate < todayUtc) {
|
||||
throw new UserFacingError(
|
||||
"Vergangene Notdienste können nicht geändert werden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { planId, date: parsedDate },
|
||||
include: {
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let newChild: {
|
||||
id: string;
|
||||
family: {
|
||||
users: { email: string; firstName: string; lastName: string }[];
|
||||
};
|
||||
} | null = null;
|
||||
|
||||
if (newChildId) {
|
||||
newChild = await tx.child.findFirst({
|
||||
where: {
|
||||
id: newChildId,
|
||||
kitaId,
|
||||
active: true,
|
||||
},
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
@@ -204,133 +243,118 @@ export async function updateAssignmentAction(
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
let newChild:
|
||||
| {
|
||||
id: string;
|
||||
family: {
|
||||
users: { email: string; firstName: string; lastName: string }[];
|
||||
};
|
||||
if (!newChild) {
|
||||
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
| null = null;
|
||||
|
||||
if (newChildId) {
|
||||
newChild = await tx.child.findFirst({
|
||||
where: {
|
||||
id: newChildId,
|
||||
kitaId,
|
||||
active: true,
|
||||
},
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!newChild) {
|
||||
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAssignment?.childId === newChildId) {
|
||||
return [];
|
||||
}
|
||||
if (existingAssignment?.childId === newChildId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!newChildId) {
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstAssignment.delete({
|
||||
if (existingAssignment)
|
||||
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
|
||||
|
||||
if (!newChildId) {
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstAssignment.delete({
|
||||
where: { id: existingAssignment.id },
|
||||
});
|
||||
}
|
||||
} else if (existingAssignment) {
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: existingAssignment.id },
|
||||
data: { childId: newChildId },
|
||||
});
|
||||
} else {
|
||||
await tx.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId,
|
||||
planId,
|
||||
childId: newChildId,
|
||||
date: parsedDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (existingAssignment) {
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: existingAssignment.id },
|
||||
data: { childId: newChildId },
|
||||
});
|
||||
} else {
|
||||
await tx.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId,
|
||||
planId,
|
||||
childId: newChildId,
|
||||
date: parsedDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
status: "PENDING",
|
||||
OR: [
|
||||
{ targetAssignmentId: existingAssignment.id },
|
||||
{ offeredAssignmentId: existingAssignment.id },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublished) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
|
||||
const messages: {
|
||||
parentEmails: string[];
|
||||
parentNames: string[];
|
||||
subject: string;
|
||||
heading: string;
|
||||
preview: string;
|
||||
body: string;
|
||||
dateLabel: string;
|
||||
}[] = [];
|
||||
|
||||
if (existingAssignment) {
|
||||
const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
|
||||
if (oldParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: oldParents.map((p) => p.email),
|
||||
parentNames: oldParents.map((p) => `${p.firstName} ${p.lastName}`),
|
||||
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
|
||||
heading: "Notdienst wurde geändert",
|
||||
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
|
||||
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
|
||||
dateLabel,
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
status: "PENDING",
|
||||
OR: [
|
||||
{ targetAssignmentId: existingAssignment.id },
|
||||
{ offeredAssignmentId: existingAssignment.id },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newChild && existingAssignment?.childId !== newChild.id) {
|
||||
const newParents = newChild.family.users.filter((u) => !!u.email);
|
||||
if (newParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: newParents.map((p) => p.email),
|
||||
parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
|
||||
subject: `Notdienst-Einteilung am ${dateLabel}`,
|
||||
heading: "Neuer Notdienst-Termin zugewiesen",
|
||||
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
|
||||
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
|
||||
dateLabel,
|
||||
});
|
||||
if (!isPublished) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
const dateLabel = format(
|
||||
new Date(yr, mo - 1, dy),
|
||||
"EEEE, dd. MMMM yyyy",
|
||||
{ locale: de },
|
||||
);
|
||||
const messages: {
|
||||
parentEmails: string[];
|
||||
parentNames: string[];
|
||||
subject: string;
|
||||
heading: string;
|
||||
preview: string;
|
||||
body: string;
|
||||
dateLabel: string;
|
||||
}[] = [];
|
||||
|
||||
if (existingAssignment) {
|
||||
const oldParents = existingAssignment.child.family.users.filter(
|
||||
(u) => !!u.email,
|
||||
);
|
||||
if (oldParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: oldParents.map((p) => p.email),
|
||||
parentNames: oldParents.map(
|
||||
(p) => `${p.firstName} ${p.lastName}`,
|
||||
),
|
||||
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
|
||||
heading: "Notdienst wurde geändert",
|
||||
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
|
||||
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
|
||||
dateLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newChild && existingAssignment?.childId !== newChild.id) {
|
||||
const newParents = newChild.family.users.filter((u) => !!u.email);
|
||||
if (newParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: newParents.map((p) => p.email),
|
||||
parentNames: newParents.map(
|
||||
(p) => `${p.firstName} ${p.lastName}`,
|
||||
),
|
||||
subject: `Notdienst-Einteilung am ${dateLabel}`,
|
||||
heading: "Neuer Notdienst-Termin zugewiesen",
|
||||
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
|
||||
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
|
||||
dateLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
},
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
// E-Mails außerhalb der DB-Transaktion senden
|
||||
if (notifications.length > 0) {
|
||||
@@ -340,7 +364,10 @@ export async function updateAssignmentAction(
|
||||
});
|
||||
|
||||
if (kita) {
|
||||
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
for (const notification of notifications) {
|
||||
@@ -385,8 +412,8 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 ||
|
||||
!Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
|
||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
|
||||
|
||||
try {
|
||||
await prisma.kita.update({
|
||||
where: { id: kitaId },
|
||||
@@ -404,15 +431,18 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNotdienstRemindersAction(year: number, month: number) {
|
||||
export async function sendNotdienstRemindersAction(
|
||||
year: number,
|
||||
month: number,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${session.user.kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { error: mailConfigError };
|
||||
@@ -462,7 +492,9 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
},
|
||||
});
|
||||
|
||||
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
|
||||
const respondedFamilyIds = new Set(
|
||||
respondedFamilies.map((a) => a.child.familyId),
|
||||
);
|
||||
|
||||
// 4. Find open families (not responded yet)
|
||||
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
|
||||
@@ -474,7 +506,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
// 5. Build emails list and send
|
||||
const monthDate = new Date(year, month - 1, 1);
|
||||
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
|
||||
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
let sentCount = 0;
|
||||
@@ -496,7 +531,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
if (result.success) {
|
||||
sentCount++;
|
||||
} else {
|
||||
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
|
||||
console.error(
|
||||
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,7 +565,9 @@ export async function resetPlanAction(planId: string) {
|
||||
}
|
||||
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
||||
throw new UserFacingError("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
||||
throw new UserFacingError(
|
||||
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all assignments
|
||||
@@ -545,6 +585,8 @@ export async function resetPlanAction(planId: string) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Zurücksetzen des Plans:", error);
|
||||
return { error: publicActionError(error, "Fehler beim Zurücksetzen des Plans.") };
|
||||
return {
|
||||
error: publicActionError(error, "Fehler beim Zurücksetzen des Plans."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { addMonths, getYear, getMonth, format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { TerminType, UserRole } from "@prisma/client";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { PlanView } from "./plan-view";
|
||||
import { SettingsModal } from "./_components/settings-modal";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { todayKey } from "@/lib/alarms/format";
|
||||
|
||||
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
|
||||
|
||||
@@ -17,14 +18,18 @@ export default async function PlanungsZentralePage({
|
||||
}: {
|
||||
searchParams: Promise<{ year?: string; month?: string }>;
|
||||
}) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const now = new Date();
|
||||
const nextMonth = addMonths(now, 1);
|
||||
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
|
||||
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
|
||||
const targetYear = resolvedSearchParams.year
|
||||
? parseInt(resolvedSearchParams.year, 10)
|
||||
: getYear(nextMonth);
|
||||
const targetMonth = resolvedSearchParams.month
|
||||
? parseInt(resolvedSearchParams.month, 10)
|
||||
: getMonth(nextMonth) + 1;
|
||||
|
||||
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthName = format(monthDate, "MMMM", { locale: de });
|
||||
@@ -33,79 +38,84 @@ export default async function PlanungsZentralePage({
|
||||
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthEnd = new Date(targetYear, targetMonth, 1);
|
||||
|
||||
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
|
||||
prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
const [plan, families, availabilities, closureCount, kita] =
|
||||
await Promise.all([
|
||||
prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
alarms: {
|
||||
where: { activeAssignmentId: { not: null } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
children: { some: { active: true } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
children: {
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
children: { some: { active: true } },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
children: {
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.termin.count({
|
||||
where: {
|
||||
kitaId,
|
||||
type: TerminType.SCHLIESSTAG,
|
||||
startDate: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: {
|
||||
notdienstMinPerChildPerMonth: true,
|
||||
notdienstReminderDaysBefore: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.termin.count({
|
||||
where: {
|
||||
kitaId,
|
||||
type: TerminType.SCHLIESSTAG,
|
||||
startDate: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: {
|
||||
notdienstMinPerChildPerMonth: true,
|
||||
notdienstReminderDaysBefore: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||
// aber für MVP reichen alle aktiven Kinder.
|
||||
const allChildrenRaw = await prisma.child.findMany({
|
||||
where: { kitaId, active: true },
|
||||
@@ -179,7 +189,7 @@ export default async function PlanungsZentralePage({
|
||||
// Wir mappen die Assignments auf die Werktage
|
||||
const daysWithAssignments = workingDays.map((day) => {
|
||||
const assignment = plan?.assignments.find(
|
||||
(a) => a.date.getTime() === day.getTime()
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
const isOriginallyUnassigned =
|
||||
!assignment ||
|
||||
@@ -190,6 +200,11 @@ export default async function PlanungsZentralePage({
|
||||
assignmentId: assignment?.id || null,
|
||||
childId: assignment?.childId || null,
|
||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||
canAlarm:
|
||||
!!assignment &&
|
||||
plan?.status === "PUBLISHED" &&
|
||||
day.toISOString().slice(0, 10) >= todayKey(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { NotdienstPlanStatus } from "@prisma/client";
|
||||
@@ -40,6 +41,8 @@ interface PlanViewProps {
|
||||
days: {
|
||||
date: string;
|
||||
assignmentId: string | null;
|
||||
activeAlarmId?: string | null;
|
||||
canAlarm?: boolean;
|
||||
childId: string | null;
|
||||
isOriginallyUnassigned?: boolean;
|
||||
}[];
|
||||
@@ -92,7 +95,9 @@ export function PlanView({
|
||||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||||
|
||||
const responsePercent =
|
||||
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
|
||||
totalFamilies > 0
|
||||
? Math.round((respondedFamilies / totalFamilies) * 100)
|
||||
: 0;
|
||||
|
||||
const handleGenerate = () => {
|
||||
startTransition(async () => {
|
||||
@@ -123,7 +128,9 @@ export function PlanView({
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else if (result.success) {
|
||||
toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`);
|
||||
toast.success(
|
||||
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -173,7 +180,11 @@ export function PlanView({
|
||||
<Button disabled={isPending} onClick={handleGenerate}>
|
||||
Trotzdem generieren
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSendReminders}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
@@ -408,7 +419,8 @@ export function PlanView({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Plan zurücksetzen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
|
||||
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für
|
||||
diesen Monat gehen unwiderruflich verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -542,6 +554,21 @@ function PlanPreviewTable({
|
||||
({assignedChild.name})
|
||||
</span>
|
||||
)}
|
||||
{day.activeAlarmId ? (
|
||||
<Link
|
||||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||
href={`/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`}
|
||||
>
|
||||
Aktiven Alarm öffnen
|
||||
</Link>
|
||||
) : day.canAlarm && day.assignmentId ? (
|
||||
<Link
|
||||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||
href={`/dashboard/notdienst/alarm?assignment=${day.assignmentId}`}
|
||||
>
|
||||
Notdienstalarm auslösen
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||
<div>
|
||||
@@ -616,7 +643,7 @@ function ManualAssignmentTable({
|
||||
<select
|
||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
value={day.childId || "empty"}
|
||||
disabled={isPending}
|
||||
disabled={isPending || !!day.activeAlarmId}
|
||||
onChange={(event) =>
|
||||
onAssignmentChange(day.date, event.target.value)
|
||||
}
|
||||
@@ -624,7 +651,9 @@ function ManualAssignmentTable({
|
||||
<option value="empty">-- Unbesetzt --</option>
|
||||
{allChildren.map((child) => {
|
||||
const famId = child.familyId;
|
||||
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
|
||||
const count = famId
|
||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<option key={child.id} value={child.id}>
|
||||
{child.parentName} ({child.name}) — {count}x
|
||||
@@ -637,6 +666,20 @@ function ManualAssignmentTable({
|
||||
Veröffentlicht
|
||||
</span>
|
||||
)}
|
||||
{day.assignmentId && day.canAlarm && (
|
||||
<Link
|
||||
className="text-xs font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||
href={
|
||||
day.activeAlarmId
|
||||
? `/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`
|
||||
: `/dashboard/notdienst/alarm?assignment=${day.assignmentId}`
|
||||
}
|
||||
>
|
||||
{day.activeAlarmId
|
||||
? "Aktiven Alarm öffnen"
|
||||
: "Notdienstalarm auslösen"}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+49
-12
@@ -18,7 +18,12 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Stat } from "@/components/ui/stat";
|
||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||
import { NewsTicker } from "./news-ticker";
|
||||
import { RsvpActionCard } from "./rsvp-action-card";
|
||||
import { getAnnouncements } from "@/lib/announcements";
|
||||
import { getRsvpEvents } from "@/lib/rsvp-events";
|
||||
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
|
||||
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
|
||||
|
||||
export const metadata = { title: "Übersicht · Kita-Planer" };
|
||||
|
||||
@@ -55,6 +60,8 @@ export default async function DashboardPage() {
|
||||
onboardingUser,
|
||||
absenceChildren,
|
||||
upcomingAbsences,
|
||||
announcements,
|
||||
rsvpEvents,
|
||||
] = await Promise.all([
|
||||
prisma.family.count({
|
||||
where: { kitaId: session.user.kitaId },
|
||||
@@ -142,6 +149,8 @@ export default async function DashboardPage() {
|
||||
take: 6,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
getAnnouncements(session, { take: 3 }),
|
||||
getRsvpEvents(session, { onlyOpen: true }),
|
||||
]);
|
||||
|
||||
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
|
||||
@@ -177,7 +186,7 @@ export default async function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<header className="mb-9 flex items-start justify-between gap-8">
|
||||
<header className="mb-9 flex items-start justify-between gap-8 max-[760px]:flex-col max-[760px]:gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
|
||||
ÜBERSICHT · {kita.name}
|
||||
@@ -191,18 +200,29 @@ export default async function DashboardPage() {
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
|
||||
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
|
||||
dein Kind schnell für Abwesenheiten melden.
|
||||
siehst du auf einen Blick, was bei {kita.name} ansteht.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<div className="mt-9 max-[760px]:mt-0 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
|
||||
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||
{format(now, "EEE, d. MMM yyyy", { locale: de })}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AlarmDashboardHints />
|
||||
<RsvpActionCard events={rsvpEvents} />
|
||||
<NewsTicker items={announcements} />
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
{session.user.role === UserRole.ERZIEHER && (
|
||||
<QuickCard
|
||||
href="/dashboard/personal"
|
||||
icon={Clock3}
|
||||
title="Meine Arbeitszeit & Urlaub"
|
||||
sub="Stunden erfassen, Urlaub beantragen und dein Zeitkonto ansehen."
|
||||
/>
|
||||
)}
|
||||
{canReportAbsences ? (
|
||||
<AbsenceCard
|
||||
today={format(today, "yyyy-MM-dd")}
|
||||
@@ -237,7 +257,11 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
|
||||
<Stat label="Familien" value={familyCount} hint="registrierte Familien" />
|
||||
<Stat
|
||||
label="Familien"
|
||||
value={familyCount}
|
||||
hint="registrierte Familien"
|
||||
/>
|
||||
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
|
||||
<Stat
|
||||
label={`Notdienst ${targetData.monthName}`}
|
||||
@@ -260,12 +284,22 @@ export default async function DashboardPage() {
|
||||
Häufige Aktionen für Vorstand und Koordination.
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
|
||||
<QuickCard
|
||||
href="/dashboard/families"
|
||||
icon={Users}
|
||||
title="Familien verwalten"
|
||||
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
|
||||
/>
|
||||
{session.user.role === UserRole.ADMIN && (
|
||||
<QuickCard
|
||||
href="/dashboard/personal"
|
||||
icon={Clock3}
|
||||
title="Personal & Freigaben"
|
||||
sub="Urlaub, Arbeitswochen und Beschäftigungsprofile verwalten."
|
||||
/>
|
||||
)}
|
||||
{session.user.role === UserRole.ADMIN && (
|
||||
<QuickCard
|
||||
href="/dashboard/families"
|
||||
icon={Users}
|
||||
title="Familien verwalten"
|
||||
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
|
||||
/>
|
||||
)}
|
||||
{kita.terminModuleEnabled ? (
|
||||
<QuickCard
|
||||
href="/dashboard/kalender"
|
||||
@@ -274,7 +308,10 @@ export default async function DashboardPage() {
|
||||
sub="Schließtage, Elternabende und Feste planen."
|
||||
/>
|
||||
) : null}
|
||||
{kita.notdienstModuleEnabled ? (
|
||||
{kita.notdienstModuleEnabled &&
|
||||
[UserRole.ADMIN, UserRole.KOORDINATOR].includes(
|
||||
session.user.role as "ADMIN" | "KOORDINATOR",
|
||||
) ? (
|
||||
<QuickCard
|
||||
href="/dashboard/notdienst/plan"
|
||||
icon={ListTodo}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
import { publicActionError } from "@/lib/action-error";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireStaffActor } from "@/lib/staff/auth";
|
||||
import { executeStaffCommand } from "@/lib/staff/service";
|
||||
import { deliverStaffEmails } from "@/lib/staff/email";
|
||||
|
||||
export type StaffActionResult = {
|
||||
educatorId?: string;
|
||||
error?: string;
|
||||
success?: boolean;
|
||||
invitePath?: string;
|
||||
warning?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function staffAction(
|
||||
command: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<StaffActionResult> {
|
||||
const actor = await requireStaffActor();
|
||||
try {
|
||||
const result = await executeStaffCommand(actor, { command, data });
|
||||
let warning: string | undefined;
|
||||
let message: string | undefined;
|
||||
if (result.emailIds?.length) {
|
||||
try {
|
||||
const delivery = await deliverStaffEmails(result.emailIds);
|
||||
if (delivery.failed)
|
||||
warning =
|
||||
"Gespeichert. Einige E-Mails konnten nicht versendet werden. Im Personalbereich kannst du den Versand erneut versuchen.";
|
||||
else if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.RESEND_API_KEY
|
||||
)
|
||||
warning = "Gespeichert. Der E-Mail-Versand wurde lokal simuliert.";
|
||||
else if (result.invite && delivery.sent)
|
||||
message = "Aktivierungslink per E-Mail versendet.";
|
||||
else if (command === "emailRetry")
|
||||
message = "Ausstehende E-Mails wurden verarbeitet.";
|
||||
} catch {
|
||||
warning =
|
||||
"Gespeichert. E-Mail-Versand ist noch ausstehend; bitte im Personalbereich erneut versuchen.";
|
||||
}
|
||||
}
|
||||
revalidatePath("/dashboard/personal");
|
||||
revalidatePath("/dashboard/profil");
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
revalidatePath("/dashboard");
|
||||
// Returned only to the authenticated admin who generated this single-use link.
|
||||
return {
|
||||
success: true,
|
||||
educatorId: result.educatorId,
|
||||
warning,
|
||||
message,
|
||||
...(result.invite
|
||||
? { invitePath: `/invite/${result.invite.token}` }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return {
|
||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
};
|
||||
const message = publicActionError(error, "");
|
||||
if (message) return { error: message };
|
||||
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, useTransition, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DAY_NAMES,
|
||||
clockMinute,
|
||||
minuteClock,
|
||||
todayKey,
|
||||
} from "@/lib/staff/calculations";
|
||||
import { staffAction, type StaffActionResult } from "./actions";
|
||||
|
||||
const control =
|
||||
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
|
||||
export function Field({
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
value,
|
||||
required = true,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string | number;
|
||||
required?: boolean;
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
step?: string | number;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={value}
|
||||
required={required}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function transform(command: string, form: FormData) {
|
||||
const text = (key: string) => String(form.get(key) ?? "");
|
||||
const num = (key: string) => Number(text(key));
|
||||
const hours = (key: string) => Math.round(num(key) * 60);
|
||||
switch (command) {
|
||||
case "emailPreference":
|
||||
return { enabled: form.get("enabled") === "on" };
|
||||
case "setup":
|
||||
return {
|
||||
educatorId: text("educatorId"),
|
||||
employmentStart: text("employmentStart"),
|
||||
employmentEnd: text("employmentEnd") || null,
|
||||
trackingStart: text("trackingStart"),
|
||||
openingMinutes: hours("openingHours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "invite":
|
||||
return { email: text("email") };
|
||||
case "employmentEnd":
|
||||
return {
|
||||
employmentEnd: text("employmentEnd") || null,
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "pattern":
|
||||
return {
|
||||
effectiveFrom: text("effectiveFrom"),
|
||||
dailyMinutes: DAY_NAMES.map((_, i) => hours(`day${i}`)),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "account":
|
||||
return {
|
||||
year: num("year"),
|
||||
entitlementUnits: Math.round(num("entitlement") * 2),
|
||||
carryUnits: Math.round(num("carry") * 2),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "override":
|
||||
return {
|
||||
date: text("date"),
|
||||
minutes: hours("hours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "adjust":
|
||||
return {
|
||||
date: text("date"),
|
||||
minutes: hours("hours"),
|
||||
reason: text("reason"),
|
||||
};
|
||||
case "holiday":
|
||||
return { date: text("date"), name: text("name") };
|
||||
case "time":
|
||||
return {
|
||||
date: text("date"),
|
||||
startMinute: clockMinute(text("start")),
|
||||
endMinute: clockMinute(text("end")),
|
||||
breakMinutes: num("breakMinutes"),
|
||||
note: text("note"),
|
||||
};
|
||||
case "leave":
|
||||
return {
|
||||
kind: text("kind"),
|
||||
startDate: text("startDate"),
|
||||
endDate: text("endDate"),
|
||||
halfDay: form.get("halfDay") === "on",
|
||||
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
|
||||
note: text("note"),
|
||||
};
|
||||
case "leaveReview":
|
||||
case "weekReview":
|
||||
return { reason: text("reason") };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function StaffForm({
|
||||
command,
|
||||
profileId,
|
||||
extra = {},
|
||||
children,
|
||||
label = "Speichern",
|
||||
onSuccess,
|
||||
}: {
|
||||
command: string;
|
||||
profileId?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
onSuccess?: (result: StaffActionResult) => void;
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
try {
|
||||
const result = await staffAction(command, {
|
||||
...transform(command, form),
|
||||
...extra,
|
||||
...(profileId ? { profileId } : {}),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
onSuccess?.(result);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Speichern fehlgeschlagen.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
|
||||
{children}
|
||||
<div>
|
||||
<Button type="submit">
|
||||
{pending ? "Wird gespeichert …" : label}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
export function CommandButton({
|
||||
command,
|
||||
profileId,
|
||||
data,
|
||||
children,
|
||||
variant = "outline",
|
||||
}: {
|
||||
command: string;
|
||||
profileId: string;
|
||||
data: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
variant?: "outline" | "ghost" | "default";
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const result = await staffAction(command, { ...data, profileId });
|
||||
if (result.error) toast.error(result.error);
|
||||
else {
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
router.refresh();
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Bitte warten …" : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export function ReviewButton({
|
||||
command,
|
||||
profileId,
|
||||
data,
|
||||
label,
|
||||
approve,
|
||||
}: {
|
||||
command: "leaveReview" | "weekReview";
|
||||
profileId: string;
|
||||
data: Record<string, unknown>;
|
||||
label: string;
|
||||
approve: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant={approve ? "default" : "outline"}>
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die Entscheidung wird im Personalverlauf dokumentiert.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command={command}
|
||||
profileId={profileId}
|
||||
extra={{ ...data, approve }}
|
||||
label={label}
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<Field label="Begründung / Rückmeldung" name="reason" />
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function SetupForm({
|
||||
educatorId,
|
||||
values,
|
||||
}: {
|
||||
educatorId: string;
|
||||
values?: {
|
||||
employmentStart: string;
|
||||
employmentEnd: string | null;
|
||||
trackingStart: string;
|
||||
openingMinutes: number;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<StaffForm
|
||||
command="setup"
|
||||
extra={{ educatorId }}
|
||||
label="Beschäftigungsprofil speichern"
|
||||
>
|
||||
<input type="hidden" name="educatorId" value={educatorId} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="Beschäftigung ab"
|
||||
name="employmentStart"
|
||||
type="date"
|
||||
value={values?.employmentStart ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Beschäftigung bis (optional)"
|
||||
name="employmentEnd"
|
||||
type="date"
|
||||
value={values?.employmentEnd ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<Field
|
||||
label="Zeiterfassung ab"
|
||||
name="trackingStart"
|
||||
type="date"
|
||||
value={values?.trackingStart ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Anfangssaldo in Stunden"
|
||||
name="openingHours"
|
||||
type="number"
|
||||
step="any"
|
||||
value={(values?.openingMinutes ?? 0) / 60}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" value="Ersteinrichtung" />
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
export function TimeForm({
|
||||
profileId,
|
||||
initial,
|
||||
}: {
|
||||
profileId: string;
|
||||
initial?: {
|
||||
id: string;
|
||||
date: string;
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
breakMinutes: number;
|
||||
note: string | null;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={initial ? "outline" : "default"}
|
||||
size={initial ? "sm" : "default"}
|
||||
>
|
||||
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initial ? "Arbeitszeit bearbeiten" : "Arbeitszeit erfassen"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erfasse Beginn, Ende und die gesamte Pause dieses Arbeitsblocks.
|
||||
Weitere Blöcke am selben Tag sind möglich.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command="time"
|
||||
profileId={profileId}
|
||||
extra={initial ? { id: initial.id } : {}}
|
||||
label="Arbeitszeit speichern"
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<Field
|
||||
label="Datum"
|
||||
name="date"
|
||||
type="date"
|
||||
value={initial?.date ?? todayKey()}
|
||||
max={todayKey()}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Beginn"
|
||||
name="start"
|
||||
type="time"
|
||||
value={minuteClock(initial?.startMinute ?? 480)}
|
||||
/>
|
||||
<Field
|
||||
label="Ende"
|
||||
name="end"
|
||||
type="time"
|
||||
value={minuteClock(initial?.endMinute ?? 960)}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Pause in Minuten"
|
||||
name="breakMinutes"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={initial?.breakMinutes ?? 30}
|
||||
/>
|
||||
<Field
|
||||
label="Notiz (optional)"
|
||||
name="note"
|
||||
value={initial?.note ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function LeaveForm({
|
||||
profileId,
|
||||
admin,
|
||||
initial,
|
||||
}: {
|
||||
profileId: string;
|
||||
admin: boolean;
|
||||
initial?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
halfDay: boolean;
|
||||
minutes: number | null;
|
||||
note: string | null;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
|
||||
const id = useId();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size={initial ? "sm" : "default"} variant="outline">
|
||||
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initial ? "Antrag ändern" : "Abwesenheit beantragen"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Urlaub verbraucht nur deine vorgesehenen Arbeitstage.
|
||||
Freizeitausgleich wird in Stunden für einen einzelnen Tag beantragt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StaffForm
|
||||
command="leave"
|
||||
profileId={profileId}
|
||||
extra={initial ? { id: initial.id } : {}}
|
||||
label={
|
||||
kind === "SICKNESS" ? "Krankheit hinterlegen" : "Antrag speichern"
|
||||
}
|
||||
onSuccess={() => setOpen(false)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<label htmlFor={`${id}-kind`} className="text-sm font-medium">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id={`${id}-kind`}
|
||||
name="kind"
|
||||
className={control}
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
<option value="VACATION">Urlaub</option>
|
||||
<option value="TIME_OFF">Freizeitausgleich</option>
|
||||
{admin && <option value="SICKNESS">Krankheit</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Von"
|
||||
name="startDate"
|
||||
type="date"
|
||||
value={initial?.startDate ?? todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Bis"
|
||||
name="endDate"
|
||||
type="date"
|
||||
value={initial?.endDate ?? todayKey()}
|
||||
/>
|
||||
</div>
|
||||
{kind === "VACATION" && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
name="halfDay"
|
||||
type="checkbox"
|
||||
defaultChecked={initial?.halfDay}
|
||||
/>
|
||||
Halber Urlaubstag (Von = Bis)
|
||||
</label>
|
||||
)}
|
||||
{kind === "TIME_OFF" && (
|
||||
<Field
|
||||
label="Freizeitausgleich in Stunden (Von = Bis)"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={1 / 60}
|
||||
max={16}
|
||||
step="any"
|
||||
value={(initial?.minutes ?? 60) / 60}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Notiz (optional)"
|
||||
name="note"
|
||||
value={initial?.note ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Der Antrag wird anhand deines Wochenmodells, der Tagesausnahmen und
|
||||
der Feiertage geprüft. Ein halber Tag entspricht der Hälfte der
|
||||
Sollzeit.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
function SettingsSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5">
|
||||
<summary className="cursor-pointer font-semibold">{title}</summary>
|
||||
<div className="mt-5">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export function StaffEmailSettings({ enabled }: { enabled: boolean }) {
|
||||
return (
|
||||
<StaffForm command="emailPreference" label="E-Mail-Einstellung speichern">
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
defaultChecked={enabled}
|
||||
className="mt-1 accent-[var(--accent-deep)]"
|
||||
/>
|
||||
<span>
|
||||
Entscheidungen zu Urlaub, Freizeitausgleich und Arbeitswochen per
|
||||
E-Mail erhalten.
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Aktivierungslinks und Hinweise innerhalb der App bleiben davon
|
||||
unabhängig.
|
||||
</p>
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
profileId,
|
||||
educatorId,
|
||||
employment,
|
||||
email,
|
||||
verified,
|
||||
pattern,
|
||||
year,
|
||||
account,
|
||||
}: {
|
||||
profileId: string;
|
||||
educatorId: string;
|
||||
employment: {
|
||||
employmentStart: string;
|
||||
employmentEnd: string | null;
|
||||
trackingStart: string;
|
||||
openingMinutes: number;
|
||||
};
|
||||
email?: string;
|
||||
verified: boolean;
|
||||
pattern?: { effectiveFrom: string; dailyMinutes: number[] };
|
||||
year: number;
|
||||
account?: { entitlementUnits: number; carryUnits: number };
|
||||
}) {
|
||||
const [invitePath, setInvitePath] = useState("");
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<SettingsSection title="Zugang zur Zeiterfassung">
|
||||
{verified ? (
|
||||
<p className="text-sm">Aktives Konto: {email}</p>
|
||||
) : (
|
||||
<StaffForm
|
||||
command="invite"
|
||||
profileId={profileId}
|
||||
label="Per E-Mail einladen / Account verknüpfen"
|
||||
onSuccess={(r) => setInvitePath(r.invitePath ?? "")}
|
||||
>
|
||||
<Field
|
||||
label="E-Mail-Adresse"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email ?? ""}
|
||||
/>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
|
||||
persönliche Aktivierungslink wird per E-Mail versendet und ist
|
||||
sieben Tage gültig. Ein neuer Link ersetzt den vorherigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
)}
|
||||
{invitePath && (
|
||||
<div className="mt-4 grid gap-2 rounded-lg bg-[var(--accent-soft)] p-4">
|
||||
<label htmlFor="activation-link" className="text-sm font-medium">
|
||||
Persönlicher Aktivierungslink
|
||||
</label>
|
||||
<Input
|
||||
id="activation-link"
|
||||
readOnly
|
||||
value={`${window.location.origin}${invitePath}`}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`${window.location.origin}${invitePath}`,
|
||||
);
|
||||
toast.success("Link kopiert.");
|
||||
}}
|
||||
>
|
||||
Link kopieren
|
||||
</Button>
|
||||
<p className="text-xs">
|
||||
Der Link wird per E-Mail versendet. Bei Bedarf kannst du ihn
|
||||
zusätzlich persönlich an die Fachkraft weitergeben.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Feste Wochentage & Sollstunden">
|
||||
<StaffForm
|
||||
command="pattern"
|
||||
profileId={profileId}
|
||||
label="Wochenmodell speichern"
|
||||
>
|
||||
<Field
|
||||
label="Gültig ab"
|
||||
name="effectiveFrom"
|
||||
type="date"
|
||||
value={pattern?.effectiveFrom ?? employment.trackingStart}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{DAY_NAMES.map((day, i) => (
|
||||
<Field
|
||||
key={day}
|
||||
label={`${day} · Stunden`}
|
||||
name={`day${i}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
step="any"
|
||||
value={(pattern?.dailyMinutes[i] ?? 0) / 60}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
0 Stunden = frei. Stunden sind Nettozeiten ohne Pause. Änderungen
|
||||
gelten ab dem gewählten Datum; ältere Modelle bleiben erhalten.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={`Urlaubsanspruch & Übertrag · ${year}`}>
|
||||
<StaffForm command="account" profileId={profileId}>
|
||||
<Field
|
||||
label="Jahr"
|
||||
name="year"
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2100}
|
||||
value={year}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Jahresanspruch in Tagen"
|
||||
name="entitlement"
|
||||
type="number"
|
||||
min={0}
|
||||
max={400}
|
||||
step={0.5}
|
||||
value={(account?.entitlementUnits ?? 0) / 2}
|
||||
/>
|
||||
<Field
|
||||
label="Übertrag in Tagen"
|
||||
name="carry"
|
||||
type="number"
|
||||
min={0}
|
||||
max={400}
|
||||
step={0.5}
|
||||
value={(account?.carryUnits ?? 0) / 2}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Anspruch individuell für die Arbeitstage dieser Person hinterlegen.
|
||||
Der Übertrag wird ausdrücklich festgelegt.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Ausnahme für einen einzelnen Tag">
|
||||
<StaffForm command="override" profileId={profileId}>
|
||||
<Field label="Datum" name="date" type="date" value={todayKey()} />
|
||||
<Field
|
||||
label="Abweichende Sollstunden (0 = frei)"
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
step="any"
|
||||
value={0}
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Bei einem Tausch beide Tage erfassen: den ursprünglichen Tag auf 0
|
||||
setzen und den neuen Arbeitstag mit Sollstunden hinterlegen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Zeitkonto korrigieren">
|
||||
<StaffForm command="adjust" profileId={profileId}>
|
||||
<Field
|
||||
label="Buchungsdatum"
|
||||
name="date"
|
||||
type="date"
|
||||
value={todayKey()}
|
||||
/>
|
||||
<Field
|
||||
label="Korrektur in Stunden (mit Vorzeichen)"
|
||||
name="hours"
|
||||
type="number"
|
||||
step="any"
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Korrekturen bleiben im Verlauf erhalten. Falsche Buchungen durch
|
||||
eine Gegenbuchung berichtigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Beschäftigungsbeginn & Anfangssaldo">
|
||||
<SetupForm educatorId={educatorId} values={employment} />
|
||||
<p className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||
Die Grunddaten sind geschützt, sobald Zeiten, Wochen oder Anträge
|
||||
erfasst wurden.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Beschäftigungsende ändern">
|
||||
<StaffForm command="employmentEnd" profileId={profileId}>
|
||||
<Field
|
||||
label="Beschäftigung bis (leer = unbefristet)"
|
||||
name="employmentEnd"
|
||||
type="date"
|
||||
value={employment.employmentEnd ?? ""}
|
||||
required={false}
|
||||
/>
|
||||
<Field label="Begründung" name="reason" />
|
||||
</StaffForm>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function HolidayForm() {
|
||||
return (
|
||||
<StaffForm command="holiday" label="Feiertag speichern">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Datum" name="date" type="date" />
|
||||
<Field label="Bezeichnung" name="name" />
|
||||
</div>
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,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,
|
||||
@@ -27,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
||||
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||
import { ContactForm } from "./_components/contact-form";
|
||||
import { StaffEmailSettings } from "../personal/forms";
|
||||
|
||||
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
||||
|
||||
@@ -52,6 +55,7 @@ export default async function ProfilPage() {
|
||||
postalCode: true,
|
||||
city: true,
|
||||
directoryOptInAt: true,
|
||||
staffDecisionEmails: true,
|
||||
createdAt: true,
|
||||
family: {
|
||||
select: {
|
||||
@@ -96,7 +100,9 @@ export default async function ProfilPage() {
|
||||
</em>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
||||
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."}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -124,25 +130,31 @@ export default async function ProfilPage() {
|
||||
label="Telefon"
|
||||
value={user.phone ? user.phone : <EmptyDefValue />}
|
||||
/>
|
||||
<DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
|
||||
<DefItem
|
||||
icon={Home}
|
||||
label="Haushalt"
|
||||
value={
|
||||
user.family ? (
|
||||
user.family.name
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
|
||||
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
|
||||
Dein Account ist noch keinem Haushalt zugeordnet.
|
||||
</p>
|
||||
<CreateFamilyDialog
|
||||
defaultFamilyName={`Familie ${user.lastName}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
icon={Shield}
|
||||
label="Rolle"
|
||||
value={<RoleBadge role={user.role} />}
|
||||
/>
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<DefItem
|
||||
icon={Home}
|
||||
label="Haushalt"
|
||||
value={
|
||||
user.family ? (
|
||||
user.family.name
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
|
||||
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
|
||||
Dein Account ist noch keinem Haushalt zugeordnet.
|
||||
</p>
|
||||
<CreateFamilyDialog
|
||||
defaultFamilyName={`Familie ${user.lastName}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DefItem
|
||||
icon={CalendarHeart}
|
||||
label="Mitglied seit"
|
||||
@@ -153,6 +165,24 @@ export default async function ProfilPage() {
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Meine Arbeitszeit & Urlaub</CardTitle>
|
||||
<CardDescription>
|
||||
Wochenmodell, Urlaubsanspruch, Zeiterfassung und Überstunden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link
|
||||
href="/dashboard/personal"
|
||||
className="font-medium text-[var(--accent-deep)] underline underline-offset-4"
|
||||
>
|
||||
Personalbereich öffnen →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -180,87 +210,104 @@ export default async function ProfilPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Baby className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Kinder</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Kinder deines Haushalts verwalten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MyChildrenManager
|
||||
items={(user.family?.children ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth
|
||||
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
canManage={!!user.familyId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Ämter & Dienste</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Feste Aufgaben und laufende Zuständigkeiten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.dutyAssignments.length === 0 ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
|
||||
Keine festen Ämter übernommen
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">
|
||||
Dienste und Rollen erscheinen hier, sobald sie zugewiesen
|
||||
sind.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{user.dutyAssignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||
{assignment.duty.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
laufender Dienst
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
|
||||
Aktiv
|
||||
</span>
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>E-Mail-Hinweise</CardTitle>
|
||||
<CardDescription>
|
||||
Benachrichtigungen aus dem Personalbereich.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StaffEmailSettings enabled={user.staffDecisionEmails} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Baby className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<CardTitle>Meine Kinder</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Kinder deines Haushalts verwalten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MyChildrenManager
|
||||
items={(user.family?.children ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
firstName: child.firstName,
|
||||
lastName: child.lastName,
|
||||
dateOfBirth: child.dateOfBirth
|
||||
? child.dateOfBirth.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
canManage={!!user.familyId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Meine Ämter & Dienste</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Feste Aufgaben und laufende Zuständigkeiten.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.dutyAssignments.length === 0 ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||||
<Shield className="h-4 w-4 [stroke-width:1.7]" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
|
||||
Keine festen Ämter übernommen
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">
|
||||
Dienste und Rollen erscheinen hier, sobald sie
|
||||
zugewiesen sind.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{user.dutyAssignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink)]">
|
||||
{assignment.duty.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
laufender Dienst
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { EventParticipationStatus } from "@prisma/client";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { AlertTriangle, Check, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { submitEventRsvp } from "@/actions/rsvp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { RsvpEventDto } from "@/lib/rsvp";
|
||||
import { RsvpDialog } from "@/components/rsvp-form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,41 +12,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export type DashboardRsvpEventDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
rsvpDeadline: string | null;
|
||||
children: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EventParticipationStatus | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] }) {
|
||||
export function RsvpActionCard({ events }: { events: RsvpEventDto[] }) {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="mb-8 border-[var(--warn)] bg-[var(--warn-soft)]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[var(--ink)]">
|
||||
<AlertTriangle className="h-5 w-5 text-[var(--warn)] [stroke-width:1.7]" />
|
||||
Aktion erforderlich
|
||||
<Card className="mb-5 border-[var(--warn)] bg-[var(--warn-soft)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-[var(--warn)]" />
|
||||
Rückmeldung erforderlich
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[var(--ink-soft)]">
|
||||
Für diese Termine fehlt noch mindestens eine Rückmeldung deiner
|
||||
Familie.
|
||||
<CardDescription>
|
||||
Für diese Termine fehlt noch eine Antwort eurer Familie.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
@@ -61,7 +33,7 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
|
||||
className="flex flex-col gap-3 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--ink)]">{event.title}</p>
|
||||
<p className="font-medium">{event.title}</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Rückmeldung bis{" "}
|
||||
{format(
|
||||
@@ -78,130 +50,3 @@ export function RsvpActionCard({ events }: { events: DashboardRsvpEventDto[] })
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RsvpDialog({ event }: { event: DashboardRsvpEventDto }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const initialSelection = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
event.children.map((child) => [child.id, child.status ?? ""]),
|
||||
) as Record<string, EventParticipationStatus | "">,
|
||||
[event.children],
|
||||
);
|
||||
const [selection, setSelection] = useState(initialSelection);
|
||||
|
||||
function submit() {
|
||||
const missingChild = event.children.find((child) => !selection[child.id]);
|
||||
if (missingChild) {
|
||||
toast.error(`Bitte Rückmeldung für ${missingChild.name} auswählen.`);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
for (const child of event.children) {
|
||||
const status = selection[child.id];
|
||||
if (!status) continue;
|
||||
|
||||
const result = await submitEventRsvp({
|
||||
eventId: event.id,
|
||||
childId: child.id,
|
||||
status,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success("Rückmeldung gespeichert.");
|
||||
setOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) setSelection(initialSelection);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">Jetzt abstimmen</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Wähle pro Kind aus, ob es am Termin teilnimmt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-2">
|
||||
{event.children.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="font-medium text-[var(--ink)]">{child.name}</p>
|
||||
{child.status && (
|
||||
<Badge variant="outline">bereits gemeldet</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
|
||||
<input
|
||||
type="radio"
|
||||
name={`rsvp-${child.id}`}
|
||||
checked={
|
||||
selection[child.id] ===
|
||||
EventParticipationStatus.ATTENDING
|
||||
}
|
||||
onChange={() =>
|
||||
setSelection((current) => ({
|
||||
...current,
|
||||
[child.id]: EventParticipationStatus.ATTENDING,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Check className="h-4 w-4 text-[var(--good)] [stroke-width:1.7]" />
|
||||
Nimmt teil
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]">
|
||||
<input
|
||||
type="radio"
|
||||
name={`rsvp-${child.id}`}
|
||||
checked={
|
||||
selection[child.id] ===
|
||||
EventParticipationStatus.NOT_ATTENDING
|
||||
}
|
||||
onChange={() =>
|
||||
setSelection((current) => ({
|
||||
...current,
|
||||
[child.id]: EventParticipationStatus.NOT_ATTENDING,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<X className="h-4 w-4 text-[var(--danger)] [stroke-width:1.7]" />
|
||||
Nimmt nicht teil
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" disabled={isPending} onClick={submit}>
|
||||
{isPending ? "Speichern..." : "Rückmeldung speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ClipboardList,
|
||||
Stethoscope,
|
||||
Megaphone,
|
||||
Clock3,
|
||||
BellRing,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavItem } from "@/components/ui/nav-item";
|
||||
@@ -19,6 +21,22 @@ import { NavItem } from "@/components/ui/nav-item";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/notdienst/alarm",
|
||||
label: "Notdienstalarm",
|
||||
icon: BellRing,
|
||||
exact: true,
|
||||
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN],
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/personal",
|
||||
label: "Personal & Arbeitszeit",
|
||||
icon: Clock3,
|
||||
exact: false,
|
||||
allowedRoles: [UserRole.ADMIN, UserRole.ERZIEHER],
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard",
|
||||
@@ -26,6 +44,13 @@ const navItems = [
|
||||
icon: LayoutDashboard,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/mitteilungen",
|
||||
label: "Mitteilungen",
|
||||
icon: Megaphone,
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
href: "/dashboard/notdienst",
|
||||
@@ -33,6 +58,7 @@ const navItems = [
|
||||
icon: CalendarCheck2,
|
||||
exact: true,
|
||||
badge: "4",
|
||||
allowedRoles: [UserRole.ADMIN, UserRole.KOORDINATOR, UserRole.ELTERN],
|
||||
},
|
||||
{
|
||||
section: "Heute",
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, useTransition } from "react";
|
||||
import { EventParticipationStatus, RsvpMode } from "@prisma/client";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { toast } from "sonner";
|
||||
import { submitEventRsvp } from "@/actions/rsvp";
|
||||
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function RsvpDialog({ event }: { event: RsvpEventDto }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
{!event.canReply
|
||||
? "Rückmeldung ansehen"
|
||||
: hasMissingRsvp(event)
|
||||
? "Jetzt antworten"
|
||||
: "Antwort ändern"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rückmeldung für {event.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{event.mode === RsvpMode.FAMILY
|
||||
? "Eine gemeinsame Antwort für eure Familie."
|
||||
: "Eine Rückmeldung für jedes eigene Kind."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RsvpForm
|
||||
key={JSON.stringify(event)}
|
||||
event={event}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RsvpForm({
|
||||
event,
|
||||
onSaved,
|
||||
}: {
|
||||
event: RsvpEventDto;
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [familyStatus, setFamilyStatus] = useState<
|
||||
EventParticipationStatus | ""
|
||||
>(event.familyResponse?.status ?? "");
|
||||
const [adultCount, setAdultCount] = useState(
|
||||
String(event.familyResponse?.adultCount || 1),
|
||||
);
|
||||
const [selection, setSelection] = useState<
|
||||
Record<string, EventParticipationStatus | "">
|
||||
>(
|
||||
Object.fromEntries(
|
||||
event.children.map((child) => [child.id, child.status ?? ""]),
|
||||
),
|
||||
);
|
||||
const isFamily = event.mode === RsvpMode.FAMILY;
|
||||
const targets = isFamily
|
||||
? [{ id: "family", name: "Eure Familie", status: familyStatus }]
|
||||
: event.children.map((child) => ({
|
||||
...child,
|
||||
status: selection[child.id],
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!event.canReply || isPending) return;
|
||||
if (targets.some((target) => !target.status)) {
|
||||
toast.error("Bitte für alle eine Antwort auswählen.");
|
||||
return;
|
||||
}
|
||||
const count =
|
||||
familyStatus === EventParticipationStatus.ATTENDING
|
||||
? Number(adultCount)
|
||||
: 0;
|
||||
if (
|
||||
isFamily &&
|
||||
(!Number.isSafeInteger(count) ||
|
||||
count <
|
||||
(familyStatus === EventParticipationStatus.ATTENDING ? 1 : 0))
|
||||
) {
|
||||
toast.error("Bitte eine gültige Anzahl Erwachsener angeben.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await submitEventRsvp(
|
||||
isFamily
|
||||
? {
|
||||
eventId: event.id,
|
||||
mode: RsvpMode.FAMILY,
|
||||
status: familyStatus,
|
||||
adultCount: count,
|
||||
}
|
||||
: {
|
||||
eventId: event.id,
|
||||
mode: RsvpMode.CHILD,
|
||||
responses: event.children.map((child) => ({
|
||||
childId: child.id,
|
||||
status: selection[child.id],
|
||||
})),
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Rückmeldung gespeichert.");
|
||||
onSaved?.();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
{isFamily ? "Antwort pro Familie" : "Antwort pro Kind"} · Rückmeldung
|
||||
bis{" "}
|
||||
{format(
|
||||
new Date(event.rsvpDeadline ?? event.startDate),
|
||||
"dd.MM.yyyy HH:mm",
|
||||
{ locale: de },
|
||||
)}
|
||||
</p>
|
||||
<fieldset
|
||||
disabled={isPending || !event.canReply}
|
||||
className="grid gap-3 disabled:opacity-75"
|
||||
>
|
||||
<legend className="sr-only">Teilnahme auswählen</legend>
|
||||
{targets.map((target) => (
|
||||
<fieldset
|
||||
key={target.id}
|
||||
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4"
|
||||
>
|
||||
<legend className="px-1 text-sm font-medium text-[var(--ink)]">
|
||||
{target.name}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
EventParticipationStatus.ATTENDING,
|
||||
EventParticipationStatus.NOT_ATTENDING,
|
||||
] as const
|
||||
).map((status) => (
|
||||
<label
|
||||
key={status}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] p-3 text-sm"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${id}-${target.id}`}
|
||||
value={status}
|
||||
required
|
||||
checked={target.status === status}
|
||||
onChange={() =>
|
||||
isFamily
|
||||
? setFamilyStatus(status)
|
||||
: setSelection((current) => ({
|
||||
...current,
|
||||
[target.id]: status,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{status === EventParticipationStatus.ATTENDING
|
||||
? "Nimmt teil"
|
||||
: "Nimmt nicht teil"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
{isFamily && familyStatus === EventParticipationStatus.ATTENDING ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${id}-adults`}>
|
||||
Anzahl teilnehmender Erwachsener
|
||||
</Label>
|
||||
<Input
|
||||
id={`${id}-adults`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
required
|
||||
value={adultCount}
|
||||
onChange={(e) => setAdultCount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Antwort gilt für euren gemeinsamen Haushalt und ist für beide
|
||||
Elternkonten sichtbar.
|
||||
</p>
|
||||
{event.canReply ? (
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Speichern…" : "Rückmeldung speichern"}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-[var(--ink-muted)]">
|
||||
Die Rückmeldefrist ist abgelaufen. Gespeicherte Antworten bleiben
|
||||
sichtbar.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -16,12 +16,14 @@ type InviteEmailProps = {
|
||||
parentName: string;
|
||||
kitaName: string;
|
||||
inviteLink: string;
|
||||
staffAccount?: boolean;
|
||||
};
|
||||
|
||||
export function InviteEmail({
|
||||
parentName,
|
||||
kitaName,
|
||||
inviteLink,
|
||||
staffAccount = false,
|
||||
}: InviteEmailProps) {
|
||||
return (
|
||||
<Html lang="de">
|
||||
@@ -36,13 +38,16 @@ export function InviteEmail({
|
||||
<Text style={styles.kicker}>Einladung zur Kita-App</Text>
|
||||
<Heading style={styles.heading}>Willkommen bei {kitaName}</Heading>
|
||||
<Text style={styles.lead}>
|
||||
Hallo {parentName}, dein Eltern-Account wurde vorbereitet.
|
||||
Hallo {parentName}, dein {staffAccount ? "Fachkraft" : "Eltern"}
|
||||
-Account wurde vorbereitet.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.text}>
|
||||
Über den folgenden Link kannst du dein Passwort setzen und deinen
|
||||
Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
|
||||
Termine und deine Familiendaten.
|
||||
Account aktivieren.{" "}
|
||||
{staffAccount
|
||||
? "Danach kannst du deine Arbeitszeiten erfassen, Urlaub beantragen und dein Zeitkonto einsehen. Der Link ist sieben Tage gültig und kann einmal verwendet werden."
|
||||
: "Danach hast du Zugriff auf die Kita-Planung, Termine und deine Familiendaten."}
|
||||
</Text>
|
||||
|
||||
<Button href={inviteLink} style={styles.button}>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||
|
||||
export function NotdienstAlarmEmail({
|
||||
name,
|
||||
kitaName,
|
||||
message,
|
||||
link,
|
||||
request,
|
||||
cancelled,
|
||||
}: {
|
||||
name: string;
|
||||
kitaName: string;
|
||||
message: string;
|
||||
link: string;
|
||||
request: boolean;
|
||||
cancelled: boolean;
|
||||
}) {
|
||||
const title = request
|
||||
? "Notdienst: Eure Rückmeldung zählt"
|
||||
: cancelled
|
||||
? "Notdiensteinsatz storniert"
|
||||
: "Neuer Stand beim Notdienst";
|
||||
return (
|
||||
<Html lang="de">
|
||||
<Head />
|
||||
<Preview>
|
||||
{title} · {kitaName}
|
||||
</Preview>
|
||||
<Body style={baseBodyStyle}>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "600px",
|
||||
padding: "32px 24px",
|
||||
fontFamily: fontStack,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textMuted,
|
||||
fontSize: "12px",
|
||||
letterSpacing: "0.08em",
|
||||
}}
|
||||
>
|
||||
KITA-PLANER · {kitaName}
|
||||
</Text>
|
||||
<Heading style={{ color: request ? "#9f1239" : colors.textPrimary }}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text>Hallo {name},</Text>
|
||||
<Text style={{ lineHeight: "26px" }}>{message}</Text>
|
||||
{request && (
|
||||
<Text>
|
||||
Eine Antwort gilt für euren ganzen Haushalt. Bitte wählt auf der
|
||||
Rückmeldeseite „Wir übernehmen“ oder „Wir können nicht“.
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
href={link}
|
||||
style={{
|
||||
backgroundColor: request ? "#9f1239" : colors.brandGreen,
|
||||
color: "#fff",
|
||||
padding: "14px 20px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
{request
|
||||
? "Für unseren Haushalt antworten"
|
||||
: "Notdienstalarm öffnen"}
|
||||
</Button>
|
||||
<Text style={{ fontSize: "12px", color: colors.textMuted }}>
|
||||
Falls der Button nicht funktioniert: {link}
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||
|
||||
export function StaffNotificationEmail({
|
||||
name,
|
||||
kitaName,
|
||||
message,
|
||||
link,
|
||||
}: {
|
||||
name: string;
|
||||
kitaName: string;
|
||||
message: string;
|
||||
link: string;
|
||||
}) {
|
||||
return (
|
||||
<Html lang="de">
|
||||
<Head />
|
||||
<Preview>Neuer Hinweis im Personalbereich von {kitaName}</Preview>
|
||||
<Body style={baseBodyStyle}>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "600px",
|
||||
padding: "32px 24px",
|
||||
fontFamily: fontStack,
|
||||
}}
|
||||
>
|
||||
<Heading style={{ color: colors.textPrimary }}>
|
||||
Personalbereich · {kitaName}
|
||||
</Heading>
|
||||
<Text>Hallo {name},</Text>
|
||||
<Text style={{ lineHeight: "24px" }}>{message}</Text>
|
||||
<Text>
|
||||
Die Einzelheiten findest du nach der Anmeldung im Personalbereich.
|
||||
</Text>
|
||||
<Button
|
||||
href={link}
|
||||
style={{
|
||||
backgroundColor: colors.brandGreen,
|
||||
color: "#fff",
|
||||
padding: "14px 20px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
Personalbereich öffnen
|
||||
</Button>
|
||||
<Text style={{ fontSize: "12px", color: colors.textMuted }}>
|
||||
Fachkräfte können Entscheidungshinweise unter „Mein Profil“
|
||||
abschalten. Hinweise innerhalb der App bleiben erhalten.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { managerRoles, validateAlarmActor, type AlarmActor } from "./service";
|
||||
|
||||
export async function requireAlarmActor(manager = true): Promise<AlarmActor> {
|
||||
const session = await requireKitaSession();
|
||||
const actor = {
|
||||
id: session.user.id!,
|
||||
kitaId: session.user.kitaId,
|
||||
role: session.user.role,
|
||||
};
|
||||
if (manager && !managerRoles.includes(actor.role)) redirect("/forbidden");
|
||||
try {
|
||||
await validateAlarmActor(prisma, actor, manager);
|
||||
} catch {
|
||||
redirect("/forbidden");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { hashToken } from "@/lib/tokens";
|
||||
import { createElement } from "react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAlarmEmail } from "@/emails/NotdienstAlarmEmail";
|
||||
import { managerRoles } from "./service";
|
||||
|
||||
export async function deliverAlarmEmails(
|
||||
ids: string[],
|
||||
send = sendAppEmail,
|
||||
now = new Date(),
|
||||
) {
|
||||
let failed = 0,
|
||||
sent = 0;
|
||||
const deliver = async (id: string) => {
|
||||
const leaseUntil = new Date(Date.now() + 5 * 60_000);
|
||||
const claimed = await prisma.notdienstAlarmEmail.updateMany({
|
||||
where: {
|
||||
id,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: new Date() } }],
|
||||
},
|
||||
data: { leaseUntil, attempts: { increment: 1 } },
|
||||
});
|
||||
if (!claimed.count) return;
|
||||
const finish = (data: {
|
||||
sentAt?: Date;
|
||||
skippedAt?: Date;
|
||||
failed: boolean;
|
||||
}) =>
|
||||
prisma.notdienstAlarmEmail.updateMany({
|
||||
where: { id, leaseUntil },
|
||||
data: { ...data, leaseUntil: null },
|
||||
});
|
||||
try {
|
||||
const job = await prisma.notdienstAlarmEmail.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { user: true, alarm: { include: { kita: true } } },
|
||||
});
|
||||
const { user, alarm } = job;
|
||||
let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
|
||||
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
|
||||
else
|
||||
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
|
||||
if (job.kind === "REQUEST") {
|
||||
const token = job.path.split("/").pop();
|
||||
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
|
||||
where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] },
|
||||
});
|
||||
eligible &&=
|
||||
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
|
||||
}
|
||||
if (!eligible) {
|
||||
await finish({ skippedAt: new Date(), failed: false });
|
||||
return;
|
||||
}
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base) throw new Error("App-Adresse fehlt.");
|
||||
const site = new URL(base);
|
||||
if (
|
||||
!["https:", "http:"].includes(site.protocol) ||
|
||||
site.username ||
|
||||
site.password
|
||||
)
|
||||
throw new Error("App-Adresse ungültig.");
|
||||
const result = await send({
|
||||
to: user.email,
|
||||
subject: job.subject,
|
||||
idempotencyKey: `notdienst-alarm/${job.id}`,
|
||||
react: createElement(NotdienstAlarmEmail, {
|
||||
name: user.firstName,
|
||||
kitaName: alarm.kita.name,
|
||||
message: job.message,
|
||||
link: new URL(job.path, site.origin).toString(),
|
||||
request: job.kind === "REQUEST",
|
||||
cancelled: job.kind === "CANCEL",
|
||||
}),
|
||||
});
|
||||
if (result.success) {
|
||||
await finish({ sentAt: new Date(), failed: false });
|
||||
sent++;
|
||||
} else {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
const unique = [...new Set(ids)];
|
||||
for (let i = 0; i < unique.length; i += 4)
|
||||
await Promise.all(unique.slice(i, i + 4).map(deliver));
|
||||
return { sent, failed };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { NotdienstAlarm } from "@prisma/client";
|
||||
import {
|
||||
addDays,
|
||||
dateSchema,
|
||||
dateKey,
|
||||
todayKey,
|
||||
minuteClock,
|
||||
} from "@/lib/staff/calculations";
|
||||
|
||||
export { dateKey, todayKey };
|
||||
const berlin = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
export function berlinInstant(date: string, minute: number) {
|
||||
dateSchema.parse(date);
|
||||
const target = new Date(`${date}T00:00:00Z`).getTime() + minute * 60_000;
|
||||
let value = target;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const parts = Object.fromEntries(
|
||||
berlin.formatToParts(new Date(value)).map((p) => [p.type, p.value]),
|
||||
);
|
||||
const represented = Date.parse(
|
||||
`${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:00Z`,
|
||||
);
|
||||
const diff = target - represented;
|
||||
if (!diff) return new Date(value);
|
||||
value += diff;
|
||||
}
|
||||
throw new Error(
|
||||
"Diese Uhrzeit existiert wegen der Zeitumstellung nicht. Bitte eine andere Uhrzeit wählen.",
|
||||
);
|
||||
}
|
||||
export function endOfAlarm(
|
||||
date: string,
|
||||
wholeDay: boolean,
|
||||
endMinute?: number | null,
|
||||
) {
|
||||
return wholeDay
|
||||
? berlinInstant(addDays(date, 1), 0)
|
||||
: berlinInstant(date, endMinute!);
|
||||
}
|
||||
export function durationLabel(
|
||||
alarm: Pick<NotdienstAlarm, "wholeDay" | "startMinute" | "endMinute">,
|
||||
) {
|
||||
return alarm.wholeDay
|
||||
? "Ganzer Kita-Tag"
|
||||
: `${minuteClock(alarm.startMinute!)}–${minuteClock(alarm.endMinute!)} Uhr`;
|
||||
}
|
||||
export function instantLabel(value: Date | string) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
export function alarmLabel(
|
||||
alarm: Pick<NotdienstAlarm, "status" | "deadline" | "expiresAt">,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (alarm.status === "CANCELLED") return "Storniert";
|
||||
if (alarm.status === "CONFIRMED") return "Übernahme bestätigt";
|
||||
if (alarm.status === "DECLINED") return "Klärung erforderlich – Absage";
|
||||
if (alarm.status === "LATE_CONFIRMATION")
|
||||
return "Verspätete Zusage – Freigabe erforderlich";
|
||||
if (alarm.status === "REVIEW_REQUIRED")
|
||||
return "Klärung erforderlich – bisherige Rückmeldungen prüfen";
|
||||
if (alarm.deadline <= now || alarm.expiresAt <= now)
|
||||
return "Klärung erforderlich – Frist abgelaufen";
|
||||
return "Antwort ausstehend";
|
||||
}
|
||||
export function needsClarification(
|
||||
alarm: Pick<NotdienstAlarm, "status" | "deadline" | "expiresAt">,
|
||||
now = new Date(),
|
||||
) {
|
||||
return (
|
||||
!["CONFIRMED", "CANCELLED"].includes(alarm.status) &&
|
||||
(alarm.status !== "PENDING" ||
|
||||
alarm.deadline <= now ||
|
||||
alarm.expiresAt <= now)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { importLegacyAlarms, processAlarmDeadlines } from "./service";
|
||||
import { deliverAlarmEmails } from "./email";
|
||||
|
||||
export function authorizeAlarmJob(
|
||||
header: string | null,
|
||||
secret = process.env.NOTDIENST_JOB_SECRET,
|
||||
) {
|
||||
if (!secret || secret.length < 32) return 503;
|
||||
const expected = Buffer.from(`Bearer ${secret}`),
|
||||
received = Buffer.from(header ?? "");
|
||||
return expected.length === received.length &&
|
||||
timingSafeEqual(expected, received)
|
||||
? 200
|
||||
: 401;
|
||||
}
|
||||
export async function runAlarmJob(
|
||||
send?: Parameters<typeof deliverAlarmEmails>[1],
|
||||
now = new Date(),
|
||||
) {
|
||||
const imported = await importLegacyAlarms();
|
||||
const deadlineEmails = await processAlarmDeadlines(now);
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||
where: {
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: [{ attempts: "asc" }, { createdAt: "asc" }],
|
||||
take: 100,
|
||||
});
|
||||
const delivery = await deliverAlarmEmails(
|
||||
jobs.map((j) => j.id),
|
||||
send,
|
||||
now,
|
||||
);
|
||||
return { imported, deadlineJobs: deadlineEmails.length, ...delivery };
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
import { UserFacingError } from "@/lib/action-error";
|
||||
import { Prisma, type NotdienstAlarm, type UserRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
dateKey,
|
||||
todayKey,
|
||||
endOfAlarm,
|
||||
berlinInstant,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
} from "./format";
|
||||
|
||||
export type AlarmActor = { id: string; kitaId: string; role: UserRole };
|
||||
export const managerRoles: UserRole[] = ["ADMIN", "KOORDINATOR"];
|
||||
const id = z.string().min(1).max(100);
|
||||
const tokenSchema = z.string().min(20).max(128);
|
||||
const note = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Bitte eine Begründung angeben.")
|
||||
.max(500);
|
||||
type Tx = Prisma.TransactionClient;
|
||||
async function transaction<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await prisma.$transaction(run, {
|
||||
isolationLevel: "Serializable",
|
||||
timeout: 15000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
["P2034", "P2002"].includes(error.code) &&
|
||||
attempt < 3
|
||||
)
|
||||
continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function validateAlarmActor(
|
||||
tx: Tx,
|
||||
actor: AlarmActor,
|
||||
manager = true,
|
||||
) {
|
||||
if (
|
||||
manager
|
||||
? !managerRoles.includes(actor.role)
|
||||
: ![...managerRoles, "ELTERN"].includes(actor.role)
|
||||
)
|
||||
throw new UserFacingError("Kein Zugriff auf die Notdienstalarmierung.");
|
||||
const user = await tx.user.findFirst({
|
||||
where: {
|
||||
id: actor.id,
|
||||
kitaId: actor.kitaId,
|
||||
role: actor.role,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
},
|
||||
});
|
||||
if (!user || (user.lockedUntil && user.lockedUntil > new Date()))
|
||||
throw new UserFacingError(
|
||||
"Dein Konto hat keinen Zugriff auf die Notdienstalarmierung.",
|
||||
);
|
||||
return user;
|
||||
}
|
||||
export async function assertAssignmentNotAlarmed(
|
||||
tx: Tx,
|
||||
assignmentId: string,
|
||||
kitaId: string,
|
||||
) {
|
||||
if (
|
||||
(await tx.notdienstAlarm.count({
|
||||
where: { kitaId, activeAssignmentId: assignmentId },
|
||||
})) ||
|
||||
(await tx.notdienstAlert.count({
|
||||
where: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
alarmId: null,
|
||||
status: { not: "CANCELLED" },
|
||||
},
|
||||
}))
|
||||
)
|
||||
throw new UserFacingError(
|
||||
"Für diese Einteilung besteht ein aktiver Alarm. Bitte den Alarm zuerst stornieren.",
|
||||
);
|
||||
}
|
||||
|
||||
async function history(
|
||||
tx: Tx,
|
||||
alarmId: string,
|
||||
actorId: string | null,
|
||||
action: string,
|
||||
details: string,
|
||||
) {
|
||||
await tx.notdienstAlarmHistory.create({
|
||||
data: { alarmId, actorId, action, details },
|
||||
});
|
||||
}
|
||||
async function queue(
|
||||
tx: Tx,
|
||||
alarm: NotdienstAlarm,
|
||||
audience: "parents" | "managers",
|
||||
kind: string,
|
||||
eventKey: string,
|
||||
message: string,
|
||||
requestTokens?: Map<string, string>,
|
||||
) {
|
||||
const users =
|
||||
audience === "managers"
|
||||
? await tx.user.findMany({
|
||||
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
})
|
||||
: await tx.user.findMany({
|
||||
where: {
|
||||
kitaId: alarm.kitaId,
|
||||
familyId: alarm.familyId,
|
||||
role: "ELTERN",
|
||||
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
|
||||
alarmRecipients: { some: { alarmId: alarm.id } },
|
||||
},
|
||||
});
|
||||
const jobs: string[] = [];
|
||||
for (const user of users) {
|
||||
const recipient =
|
||||
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
|
||||
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
|
||||
await tx.notdienstAlarmNotification.create({
|
||||
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
|
||||
});
|
||||
const job = await tx.notdienstAlarmEmail.create({
|
||||
data: {
|
||||
alarmId: alarm.id,
|
||||
userId: user.id,
|
||||
eventKey,
|
||||
kind,
|
||||
subject: `Notdienst ${dateKey(alarm.date)} · ${kind === "REQUEST" ? "Bitte Rückmeldung" : kind === "CANCEL" ? "Einsatz storniert" : "Neue Rückmeldung"}`,
|
||||
message,
|
||||
path: recipient
|
||||
? `/alert/${recipient}`
|
||||
: `/dashboard/notdienst/alarm?alarm=${alarm.id}`,
|
||||
},
|
||||
});
|
||||
jobs.push(job.id);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
export function alarmDetails(alarm: NotdienstAlarm) {
|
||||
return `${dateKey(alarm.date)} · ${durationLabel(alarm)} · ${alarm.familyName}`;
|
||||
}
|
||||
|
||||
export async function executeAlarmCommand(
|
||||
actor: AlarmActor,
|
||||
command: string,
|
||||
raw: unknown,
|
||||
now = new Date(),
|
||||
): Promise<{ alarmId?: string; emailIds: string[] }> {
|
||||
const data = z.record(z.string(), z.unknown()).parse(raw);
|
||||
return transaction(async (tx) => {
|
||||
const user = await validateAlarmActor(tx, actor, command !== "readNotice");
|
||||
if (command === "readNotice") {
|
||||
await tx.notdienstAlarmNotification.updateMany({
|
||||
where: {
|
||||
id: id.parse(data.id),
|
||||
userId: user.id,
|
||||
alarm: { kitaId: actor.kitaId },
|
||||
},
|
||||
data: { readAt: now },
|
||||
});
|
||||
return { emailIds: [] };
|
||||
}
|
||||
if (command === "create") {
|
||||
const input = z
|
||||
.object({
|
||||
assignmentId: id,
|
||||
wholeDay: z.boolean(),
|
||||
startMinute: z.number().int().min(0).max(1439).optional(),
|
||||
endMinute: z.number().int().min(1).max(1439).optional(),
|
||||
replyMinutes: z.number().int().min(1).max(1440).default(30),
|
||||
educatorId: id.optional(),
|
||||
notes: z.string().trim().max(500).optional(),
|
||||
})
|
||||
.parse(data);
|
||||
if (
|
||||
!input.wholeDay &&
|
||||
(input.startMinute === undefined ||
|
||||
input.endMinute === undefined ||
|
||||
input.endMinute <= input.startMinute)
|
||||
)
|
||||
throw new UserFacingError(
|
||||
"Bitte gültige Einsatzzeiten mit Ende nach Beginn angeben.",
|
||||
);
|
||||
const assignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { id: input.assignmentId, kitaId: actor.kitaId },
|
||||
include: {
|
||||
kita: true,
|
||||
plan: true,
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (
|
||||
!assignment ||
|
||||
assignment.child.kitaId !== actor.kitaId ||
|
||||
assignment.child.family.kitaId !== actor.kitaId ||
|
||||
assignment.plan.kitaId !== actor.kitaId
|
||||
)
|
||||
throw new UserFacingError("Einteilung nicht gefunden.");
|
||||
if (!assignment.kita.notdienstModuleEnabled)
|
||||
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
|
||||
if (assignment.plan.status !== "PUBLISHED")
|
||||
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
|
||||
const date = dateKey(assignment.date);
|
||||
if (date < todayKey(now))
|
||||
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
|
||||
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
|
||||
if (expiresAt <= now)
|
||||
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
|
||||
if (!input.wholeDay) berlinInstant(date, input.startMinute!);
|
||||
if (
|
||||
(await tx.notdienstAlarm.count({
|
||||
where: { activeAssignmentId: assignment.id },
|
||||
})) ||
|
||||
(await tx.notdienstAlert.count({
|
||||
where: {
|
||||
assignmentId: assignment.id,
|
||||
alarmId: null,
|
||||
status: { not: "CANCELLED" },
|
||||
},
|
||||
}))
|
||||
)
|
||||
throw new UserFacingError(
|
||||
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
|
||||
);
|
||||
const parents = assignment.child.family.users;
|
||||
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
|
||||
if (!parents.length)
|
||||
throw new UserFacingError(
|
||||
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
|
||||
);
|
||||
if (
|
||||
input.educatorId &&
|
||||
!(await tx.educator.count({
|
||||
where: { id: input.educatorId, kitaId: actor.kitaId, active: true },
|
||||
}))
|
||||
)
|
||||
throw new UserFacingError("Fachkraft nicht gefunden.");
|
||||
const alarm = await tx.notdienstAlarm.create({
|
||||
data: {
|
||||
kitaId: actor.kitaId,
|
||||
assignmentId: assignment.id,
|
||||
activeAssignmentId: assignment.id,
|
||||
familyId: assignment.child.familyId,
|
||||
familyName: assignment.child.family.name,
|
||||
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||
date: assignment.date,
|
||||
wholeDay: input.wholeDay,
|
||||
startMinute: input.wholeDay ? null : input.startMinute,
|
||||
endMinute: input.wholeDay ? null : input.endMinute,
|
||||
expiresAt,
|
||||
deadline: new Date(
|
||||
Math.min(
|
||||
now.getTime() + input.replyMinutes * 60_000,
|
||||
expiresAt.getTime(),
|
||||
),
|
||||
),
|
||||
triggeredAt: now,
|
||||
triggeredById: actor.id,
|
||||
educatorId: input.educatorId,
|
||||
notes: input.notes,
|
||||
recipients: {
|
||||
create: parents.map((parent) => ({
|
||||
userId: parent.id,
|
||||
token: hashToken(requestTokens.get(parent.id)!),
|
||||
sessionVersion: parent.sessionVersion,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
actor.id,
|
||||
"Alarm ausgelöst",
|
||||
alarmDetails(alarm),
|
||||
);
|
||||
const emailIds = await queue(
|
||||
tx,
|
||||
alarm,
|
||||
"parents",
|
||||
"REQUEST",
|
||||
"request",
|
||||
`${alarmDetails(alarm)}. Bitte gebt bis ${instantLabel(alarm.deadline)} Uhr für euren Haushalt Bescheid, ob ihr den Einsatz übernehmt.`,
|
||||
requestTokens,
|
||||
);
|
||||
return { alarmId: alarm.id, emailIds };
|
||||
}
|
||||
const alarm = await tx.notdienstAlarm.findFirst({
|
||||
where: { id: id.parse(data.alarmId), kitaId: actor.kitaId },
|
||||
});
|
||||
if (!alarm) throw new UserFacingError("Alarm nicht gefunden.");
|
||||
if (command === "retry") {
|
||||
const jobs = await tx.notdienstAlarmEmail.findMany({
|
||||
where: { alarmId: alarm.id, sentAt: null, skippedAt: null },
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return { alarmId: alarm.id, emailIds: jobs.map((job) => job.id) };
|
||||
}
|
||||
if (command === "cancel") {
|
||||
const reason = note.parse(data.reason);
|
||||
if (alarm.status === "CANCELLED")
|
||||
throw new UserFacingError("Der Alarm ist bereits storniert.");
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
activeAssignmentId: null,
|
||||
cancelledAt: now,
|
||||
cancelReason: reason,
|
||||
},
|
||||
});
|
||||
await history(tx, alarm.id, actor.id, "Alarm storniert", reason);
|
||||
return {
|
||||
alarmId: alarm.id,
|
||||
emailIds: await queue(
|
||||
tx,
|
||||
updated,
|
||||
"parents",
|
||||
"CANCEL",
|
||||
randomUUID(),
|
||||
`${alarmDetails(updated)}: Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (command === "recordResponse" || command === "approveLate") {
|
||||
if (alarm.status === "CANCELLED" || alarm.expiresAt <= now)
|
||||
throw new UserFacingError(
|
||||
"Dieser Alarm ist storniert oder der Einsatz bereits beendet.",
|
||||
);
|
||||
const reason = note.parse(data.reason);
|
||||
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
|
||||
throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor.");
|
||||
const approve =
|
||||
command === "approveLate" || z.boolean().parse(data.approve);
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: {
|
||||
status: approve ? "CONFIRMED" : "DECLINED",
|
||||
respondedAt: now,
|
||||
respondedById: actor.id,
|
||||
responseSource: command === "approveLate" ? "COORDINATION" : "PHONE",
|
||||
},
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
actor.id,
|
||||
command === "approveLate"
|
||||
? "Verspätete Zusage freigegeben"
|
||||
: "Rückmeldung dokumentiert",
|
||||
reason,
|
||||
);
|
||||
const message = `${alarmDetails(updated)}: ${approve ? "Übernahme bestätigt" : "Absage – Klärung erforderlich"}.`;
|
||||
const emails = await queue(
|
||||
tx,
|
||||
updated,
|
||||
"managers",
|
||||
"MANAGER",
|
||||
randomUUID(),
|
||||
message,
|
||||
);
|
||||
emails.push(
|
||||
...(await queue(
|
||||
tx,
|
||||
updated,
|
||||
"parents",
|
||||
"STATUS",
|
||||
randomUUID(),
|
||||
message,
|
||||
)),
|
||||
);
|
||||
return { alarmId: alarm.id, emailIds: emails };
|
||||
}
|
||||
throw new UserFacingError("Unbekannte Alarmaktion.");
|
||||
});
|
||||
}
|
||||
|
||||
async function recipientForToken(tx: Tx, token: string) {
|
||||
const recipient = await tx.notdienstAlarmRecipient.findFirst({
|
||||
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
|
||||
include: {
|
||||
user: true,
|
||||
alarm: { include: { kita: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
!recipient ||
|
||||
recipient.user.role !== "ELTERN" ||
|
||||
recipient.sessionVersion !== recipient.user.sessionVersion ||
|
||||
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
|
||||
recipient.alarm.expiresAt <= new Date() ||
|
||||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
|
||||
recipient.user.kitaId !== recipient.alarm.kitaId ||
|
||||
recipient.user.familyId !== recipient.alarm.familyId
|
||||
)
|
||||
return null;
|
||||
return recipient;
|
||||
}
|
||||
export async function getPublicAlarm(token: string) {
|
||||
if (!tokenSchema.safeParse(token).success) return null;
|
||||
const legacy = await prisma.notdienstAlert.findFirst({
|
||||
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] },
|
||||
select: { alarmId: true, assignmentId: true },
|
||||
});
|
||||
if (legacy && !legacy.alarmId)
|
||||
await importLegacyAlarms(undefined, legacy.assignmentId);
|
||||
const recipient = await recipientForToken(prisma, token);
|
||||
if (!recipient) return null;
|
||||
const a = recipient.alarm;
|
||||
return {
|
||||
id: a.id,
|
||||
kitaName: a.kita.name,
|
||||
firstName: recipient.user.firstName,
|
||||
familyName: a.familyName,
|
||||
date: a.date,
|
||||
wholeDay: a.wholeDay,
|
||||
startMinute: a.startMinute,
|
||||
endMinute: a.endMinute,
|
||||
deadline: a.deadline,
|
||||
expiresAt: a.expiresAt,
|
||||
status: a.status,
|
||||
};
|
||||
}
|
||||
export async function respondToAlarm(
|
||||
token: string,
|
||||
approve: boolean,
|
||||
now = new Date(),
|
||||
) {
|
||||
tokenSchema.parse(token);
|
||||
z.boolean().parse(approve);
|
||||
// A legacy token is imported before the response transaction, without sending any new request.
|
||||
await getPublicAlarm(token);
|
||||
return transaction(async (tx) => {
|
||||
const recipient = await recipientForToken(tx, token);
|
||||
if (!recipient) throw new UserFacingError("Ungültiger Rückmeldelink.");
|
||||
const alarm = recipient.alarm;
|
||||
if (alarm.status !== "PENDING")
|
||||
throw new UserFacingError(
|
||||
"Für diesen Haushalt liegt bereits eine Rückmeldung vor oder der Alarm wurde storniert.",
|
||||
);
|
||||
if (alarm.expiresAt <= now)
|
||||
throw new UserFacingError(
|
||||
"Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen.",
|
||||
);
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: {
|
||||
status: approve
|
||||
? alarm.deadline <= now
|
||||
? "LATE_CONFIRMATION"
|
||||
: "CONFIRMED"
|
||||
: "DECLINED",
|
||||
respondedAt: now,
|
||||
respondedById: recipient.user.id,
|
||||
responseSource: "LINK",
|
||||
},
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
recipient.user.id,
|
||||
approve ? "Zusage des Haushalts" : "Absage des Haushalts",
|
||||
updated.status,
|
||||
);
|
||||
const message = `${alarmDetails(updated)}: ${updated.status === "CONFIRMED" ? "Übernahme bestätigt" : updated.status === "LATE_CONFIRMATION" ? "Verspätete Zusage – bitte freigeben" : "Absage – Klärung erforderlich"}.`;
|
||||
return {
|
||||
alarmId: alarm.id,
|
||||
emailIds: await queue(
|
||||
tx,
|
||||
updated,
|
||||
"managers",
|
||||
"MANAGER",
|
||||
randomUUID(),
|
||||
message,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function processAlarmDeadlines(now = new Date(), kitaId?: string) {
|
||||
const candidates = await prisma.notdienstAlarm.findMany({
|
||||
where: {
|
||||
status: "PENDING",
|
||||
escalatedAt: null,
|
||||
deadline: { lte: now },
|
||||
...(kitaId ? { kitaId } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { deadline: "asc" },
|
||||
take: 100,
|
||||
});
|
||||
const emailIds: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
emailIds.push(
|
||||
...(await transaction(async (tx) => {
|
||||
const alarm = await tx.notdienstAlarm.findUnique({
|
||||
where: { id: candidate.id },
|
||||
});
|
||||
if (
|
||||
!alarm ||
|
||||
alarm.status !== "PENDING" ||
|
||||
alarm.escalatedAt ||
|
||||
alarm.deadline > now
|
||||
)
|
||||
return [];
|
||||
const updated = await tx.notdienstAlarm.update({
|
||||
where: { id: alarm.id },
|
||||
data: { escalatedAt: now },
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
null,
|
||||
"Antwortfrist abgelaufen",
|
||||
"Klärung erforderlich; Ersatz manuell organisieren.",
|
||||
);
|
||||
return queue(
|
||||
tx,
|
||||
updated,
|
||||
"managers",
|
||||
"MANAGER",
|
||||
"deadline",
|
||||
`${alarmDetails(updated)}: Keine Antwort innerhalb der Frist. Klärung erforderlich; bitte Familie kontaktieren oder Ersatz organisieren.`,
|
||||
);
|
||||
})),
|
||||
);
|
||||
}
|
||||
await prisma.notdienstAlarm.updateMany({
|
||||
where: {
|
||||
expiresAt: { lte: now },
|
||||
activeAssignmentId: { not: null },
|
||||
...(kitaId ? { kitaId } : {}),
|
||||
},
|
||||
data: { activeAssignmentId: null },
|
||||
});
|
||||
return emailIds;
|
||||
}
|
||||
|
||||
export async function importLegacyAlarms(
|
||||
kitaId?: string,
|
||||
assignmentId?: string,
|
||||
) {
|
||||
const groups = await prisma.notdienstAlert.findMany({
|
||||
where: {
|
||||
alarmId: null,
|
||||
...(kitaId ? { kitaId } : {}),
|
||||
...(assignmentId ? { assignmentId } : {}),
|
||||
},
|
||||
distinct: ["assignmentId"],
|
||||
select: { assignmentId: true },
|
||||
take: 100,
|
||||
});
|
||||
for (const group of groups)
|
||||
await transaction(async (tx) => {
|
||||
const assignment = await tx.notdienstAssignment.findUnique({
|
||||
where: { id: group.assignmentId },
|
||||
include: {
|
||||
child: { include: { family: true } },
|
||||
alerts: {
|
||||
where: { alarmId: null },
|
||||
include: { parentUser: true },
|
||||
orderBy: { triggeredAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assignment || !assignment.alerts.length) return;
|
||||
const old = assignment.alerts;
|
||||
const valid = old.filter(
|
||||
(a) =>
|
||||
a.parentUser.role === "ELTERN" &&
|
||||
a.parentUser.familyId === assignment.child.familyId &&
|
||||
a.parentUser.kitaId === assignment.kitaId,
|
||||
);
|
||||
const uniqueRecipients = [
|
||||
...new Map(valid.map((a) => [a.parentUserId, a])).values(),
|
||||
];
|
||||
const cancelled = old.every((a) => a.status === "CANCELLED");
|
||||
const confirmed =
|
||||
uniqueRecipients.length === old.length &&
|
||||
old.every((a) => a.status === "CONFIRMED");
|
||||
const pending =
|
||||
uniqueRecipients.length === old.length &&
|
||||
old.every((a) => a.status === "PENDING");
|
||||
const expiresAt = endOfAlarm(dateKey(assignment.date), true);
|
||||
const alarm = await tx.notdienstAlarm.create({
|
||||
data: {
|
||||
kitaId: assignment.kitaId,
|
||||
assignmentId: assignment.id,
|
||||
activeAssignmentId:
|
||||
!cancelled && expiresAt > new Date() ? assignment.id : null,
|
||||
familyId: assignment.child.familyId,
|
||||
familyName: assignment.child.family.name,
|
||||
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||
date: assignment.date,
|
||||
expiresAt,
|
||||
deadline: new Date(
|
||||
Math.min(
|
||||
old[0].triggeredAt.getTime() + 30 * 60_000,
|
||||
expiresAt.getTime(),
|
||||
),
|
||||
),
|
||||
triggeredAt: old[0].triggeredAt,
|
||||
triggeredById: old[0].triggeredById,
|
||||
educatorId: old[0].educatorId,
|
||||
notes:
|
||||
old
|
||||
.map((a) => a.notes)
|
||||
.filter(Boolean)
|
||||
.filter((note, index, all) => all.indexOf(note) === index)
|
||||
.join("\n") || null,
|
||||
status: cancelled
|
||||
? "CANCELLED"
|
||||
: confirmed
|
||||
? "CONFIRMED"
|
||||
: pending
|
||||
? "PENDING"
|
||||
: "REVIEW_REQUIRED",
|
||||
responseSource: "LEGACY",
|
||||
respondedAt: confirmed ? old[0].confirmedAt : null,
|
||||
recipients: {
|
||||
create: uniqueRecipients.map((a) => ({
|
||||
userId: a.parentUserId,
|
||||
token: a.confirmationToken,
|
||||
sessionVersion: a.parentUser.sessionVersion,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.notdienstAlert.updateMany({
|
||||
where: { id: { in: old.map((a) => a.id) } },
|
||||
data: { alarmId: alarm.id },
|
||||
});
|
||||
await history(
|
||||
tx,
|
||||
alarm.id,
|
||||
null,
|
||||
"Bisherige Alarmierung übernommen",
|
||||
`Einzelstatus erhalten. ${alarm.status === "REVIEW_REQUIRED" ? "Rückmeldungen müssen manuell geprüft werden." : "Gemeinsamer Status eindeutig."}`,
|
||||
);
|
||||
});
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
export async function getAlarmFeed(actor: AlarmActor, now = new Date()) {
|
||||
const user = await validateAlarmActor(prisma, actor, false);
|
||||
const manager = managerRoles.includes(user.role);
|
||||
if (!manager && !user.familyId) return [];
|
||||
return prisma.notdienstAlarm.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId! }),
|
||||
OR: [
|
||||
{ expiresAt: { gt: now } },
|
||||
{
|
||||
status: "CANCELLED",
|
||||
cancelledAt: { gte: new Date(now.getTime() - 86400000) },
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ date: "asc" }, { triggeredAt: "desc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
familyName: true,
|
||||
wholeDay: true,
|
||||
startMinute: true,
|
||||
endMinute: true,
|
||||
deadline: true,
|
||||
expiresAt: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AnnouncementAudience, UserRole } from "@prisma/client";
|
||||
|
||||
// Admins need access to all publications for management. Other roles only
|
||||
// receive their audience; a parent with a coordination role is still a parent.
|
||||
export function visibleAnnouncementAudiences(user: {
|
||||
role: UserRole;
|
||||
familyId?: string | null;
|
||||
}): AnnouncementAudience[] {
|
||||
if (user.role === UserRole.ADMIN) return Object.values(AnnouncementAudience);
|
||||
const audiences: AnnouncementAudience[] = [AnnouncementAudience.ALLE];
|
||||
if (user.role === UserRole.ERZIEHER)
|
||||
audiences.push(AnnouncementAudience.ERZIEHER);
|
||||
if (
|
||||
user.role !== UserRole.ERZIEHER &&
|
||||
(user.familyId || user.role === UserRole.ELTERN)
|
||||
) {
|
||||
audiences.push(AnnouncementAudience.ELTERN);
|
||||
}
|
||||
return audiences;
|
||||
}
|
||||
|
||||
export function announcementRecipientWhere(audience: AnnouncementAudience) {
|
||||
const parents = {
|
||||
role: { not: UserRole.ERZIEHER },
|
||||
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
|
||||
};
|
||||
if (audience === AnnouncementAudience.ELTERN) return parents;
|
||||
if (audience === AnnouncementAudience.ERZIEHER)
|
||||
return { role: UserRole.ERZIEHER };
|
||||
return { OR: [parents, { role: UserRole.ERZIEHER }] };
|
||||
}
|
||||
|
||||
export const announcementAudienceLabels: Record<AnnouncementAudience, string> =
|
||||
{
|
||||
ELTERN: "Eltern",
|
||||
ERZIEHER: "ErzieherInnen",
|
||||
ALLE: "Eltern und ErzieherInnen",
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import "server-only";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import type { KitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { visibleAnnouncementAudiences } from "@/lib/announcement-access";
|
||||
|
||||
export async function getAnnouncements(
|
||||
session: KitaSession,
|
||||
options: { take: number; skip?: number },
|
||||
) {
|
||||
const rows = await prisma.announcement.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
audience: { in: visibleAnnouncementAudiences(session.user) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
author: { select: { firstName: true, lastName: true } },
|
||||
reads: { where: { userId: session.user.id }, select: { id: true } },
|
||||
attachments: {
|
||||
select: { id: true, fileName: true, fileUrl: true, fileType: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take: options.take,
|
||||
skip: options.skip,
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
excerpt: row.content
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/!?(\[([^\]]+)\])\([^)]+\)/g, "$2")
|
||||
.replace(/(\*\*|__|`)/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 300),
|
||||
createdAt: format(row.createdAt, "dd.MM.yyyy HH:mm", { locale: de }),
|
||||
authorName: `${row.author.firstName} ${row.author.lastName}`,
|
||||
isUnread: row.reads.length === 0,
|
||||
attachments: row.attachments,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
// Serialize RSVP writes and changes of the response mode on the same event.
|
||||
export async function lockEvent(
|
||||
tx: Prisma.TransactionClient,
|
||||
eventId: string,
|
||||
kitaId: string,
|
||||
) {
|
||||
await tx.$queryRaw`SELECT id FROM termine WHERE id = ${eventId} AND "kitaId" = ${kitaId} FOR UPDATE`;
|
||||
}
|
||||
+12
-7
@@ -9,6 +9,7 @@ type SendAppEmailInput = {
|
||||
react: ReactNode;
|
||||
from?: string;
|
||||
replyTo?: string | string[];
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
type SendAppEmailResult =
|
||||
@@ -36,6 +37,7 @@ export async function sendAppEmail({
|
||||
react,
|
||||
from = process.env.EMAIL_FROM,
|
||||
replyTo,
|
||||
idempotencyKey,
|
||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||
if (Array.isArray(to)) {
|
||||
let id: string | undefined;
|
||||
@@ -72,13 +74,16 @@ export async function sendAppEmail({
|
||||
|
||||
try {
|
||||
resendClient ??= new Resend(process.env.RESEND_API_KEY);
|
||||
const { data, error } = await resendClient.emails.send({
|
||||
from: from!,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
replyTo,
|
||||
});
|
||||
const { data, error } = await resendClient.emails.send(
|
||||
{
|
||||
from: from!,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
replyTo,
|
||||
},
|
||||
{ idempotencyKey },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("Resend konnte die E-Mail nicht versenden:", error);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import "server-only";
|
||||
import { RsvpMode, TerminStatus, UserRole } from "@prisma/client";
|
||||
import type { KitaSession } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hasMissingRsvp, type RsvpEventDto } from "@/lib/rsvp";
|
||||
|
||||
export async function getRsvpEvents(
|
||||
session: KitaSession,
|
||||
options: { onlyOpen?: boolean } = {},
|
||||
): Promise<RsvpEventDto[]> {
|
||||
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER)
|
||||
return [];
|
||||
const now = new Date();
|
||||
const familyId = session.user.familyId;
|
||||
const [events, children] = await Promise.all([
|
||||
prisma.termin.findMany({
|
||||
where: {
|
||||
kitaId: session.user.kitaId,
|
||||
requiresRsvp: true,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
kita: { terminModuleEnabled: true },
|
||||
...(options.onlyOpen
|
||||
? {
|
||||
startDate: { gt: now },
|
||||
OR: [{ rsvpDeadline: null }, { rsvpDeadline: { gt: now } }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
startDate: true,
|
||||
rsvpDeadline: true,
|
||||
rsvpMode: true,
|
||||
participations: {
|
||||
where: { child: { familyId, active: true } },
|
||||
select: { childId: true, status: true },
|
||||
},
|
||||
familyParticipations: {
|
||||
where: { familyId },
|
||||
select: { status: true, adultCount: true },
|
||||
},
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
prisma.child.findMany({
|
||||
where: { kitaId: session.user.kitaId, familyId, active: true },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
|
||||
}),
|
||||
]);
|
||||
const rows: RsvpEventDto[] = events
|
||||
.filter(
|
||||
(event) => event.rsvpMode === RsvpMode.FAMILY || children.length > 0,
|
||||
)
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDate: event.startDate.toISOString(),
|
||||
rsvpDeadline: event.rsvpDeadline?.toISOString() ?? null,
|
||||
mode: event.rsvpMode,
|
||||
canReply:
|
||||
(event.rsvpDeadline ?? event.startDate) > now && event.startDate > now,
|
||||
familyResponse: event.familyParticipations[0] ?? null,
|
||||
children:
|
||||
event.rsvpMode === RsvpMode.CHILD
|
||||
? children.map((child) => ({
|
||||
id: child.id,
|
||||
name: `${child.firstName} ${child.lastName}`,
|
||||
status:
|
||||
event.participations.find((row) => row.childId === child.id)
|
||||
?.status ?? null,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
return options.onlyOpen ? rows.filter(hasMissingRsvp) : rows;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { EventParticipationStatus, RsvpMode, TerminType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
export const rsvpSubmissionSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
eventId: z.string().min(1),
|
||||
mode: z.literal(RsvpMode.CHILD),
|
||||
responses: z
|
||||
.array(
|
||||
z.object({
|
||||
childId: z.string().min(1),
|
||||
status: z.nativeEnum(EventParticipationStatus),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.refine(
|
||||
(rows) => new Set(rows.map((row) => row.childId)).size === rows.length,
|
||||
"Doppelte Kinderantwort",
|
||||
),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
eventId: z.string().min(1),
|
||||
mode: z.literal(RsvpMode.FAMILY),
|
||||
status: z.nativeEnum(EventParticipationStatus),
|
||||
adultCount: z.number().int().min(0).max(2147483647),
|
||||
})
|
||||
.refine(
|
||||
(row) =>
|
||||
row.status === EventParticipationStatus.ATTENDING
|
||||
? row.adultCount >= 1
|
||||
: row.adultCount === 0,
|
||||
"Bei Zusage bitte die Anzahl Erwachsener angeben; bei Absage muss sie null sein.",
|
||||
),
|
||||
]);
|
||||
|
||||
export function suggestedRsvpMode(type: TerminType): RsvpMode {
|
||||
return new Set<TerminType>([
|
||||
TerminType.ELTERNABEND,
|
||||
TerminType.MITGLIEDERVERSAMMLUNG,
|
||||
TerminType.ELTERNCAFE,
|
||||
TerminType.KITA_FEST,
|
||||
]).has(type)
|
||||
? RsvpMode.FAMILY
|
||||
: RsvpMode.CHILD;
|
||||
}
|
||||
|
||||
export type RsvpEventDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
rsvpDeadline: string | null;
|
||||
mode: RsvpMode;
|
||||
canReply: boolean;
|
||||
familyResponse: {
|
||||
status: EventParticipationStatus;
|
||||
adultCount: number;
|
||||
} | null;
|
||||
children: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EventParticipationStatus | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function hasMissingRsvp(event: RsvpEventDto) {
|
||||
return event.mode === RsvpMode.FAMILY
|
||||
? !event.familyResponse
|
||||
: event.children.some((child) => !child.status);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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<StaffActor> {
|
||||
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,
|
||||
disabledAt: true,
|
||||
emailVerifiedAt: true,
|
||||
privacyPolicyAcceptedAt: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!current?.kitaId ||
|
||||
!current.privacyPolicyAcceptedAt ||
|
||||
!!current.disabledAt || !current.emailVerifiedAt ||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const DAY_NAMES = [
|
||||
"Montag",
|
||||
"Dienstag",
|
||||
"Mittwoch",
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
"Samstag",
|
||||
"Sonntag",
|
||||
];
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Beantragt",
|
||||
APPROVED: "Genehmigt",
|
||||
REJECTED: "Abgelehnt",
|
||||
CANCEL_REQUESTED: "Stornierung beantragt",
|
||||
CANCELLED: "Storniert",
|
||||
DRAFT: "Offen",
|
||||
SUBMITTED: "Zur Prüfung",
|
||||
};
|
||||
export const KIND_LABELS: Record<string, string> = {
|
||||
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<string>,
|
||||
) {
|
||||
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<string>,
|
||||
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<string>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createElement } from "react";
|
||||
import { hashToken } from "@/lib/tokens";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { StaffNotificationEmail } from "@/emails/StaffNotificationEmail";
|
||||
|
||||
// Called after the staff transaction commits. Tests inject a transport and never send mail.
|
||||
export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
|
||||
let failed = 0;
|
||||
let sent = 0;
|
||||
const deliver = async (id: string) => {
|
||||
const now = new Date();
|
||||
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
|
||||
const claim = await prisma.staffEmail.updateMany({
|
||||
where: {
|
||||
id,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||
},
|
||||
data: { leaseUntil, attempts: { increment: 1 } },
|
||||
});
|
||||
if (!claim.count) return;
|
||||
const finish = (data: {
|
||||
sentAt?: Date;
|
||||
skippedAt?: Date;
|
||||
failed: boolean;
|
||||
}) =>
|
||||
prisma.staffEmail.updateMany({
|
||||
where: { id, leaseUntil },
|
||||
data: { ...data, leaseUntil: null },
|
||||
});
|
||||
try {
|
||||
const job = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true,
|
||||
profile: { include: { kita: true, educator: true } },
|
||||
},
|
||||
});
|
||||
const { user, profile } = job;
|
||||
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
|
||||
if (job.inviteToken) {
|
||||
const token = await prisma.verificationToken.findUnique({
|
||||
where: { token: hashToken(job.inviteToken) },
|
||||
});
|
||||
eligible &&=
|
||||
profile.userId === user.id &&
|
||||
user.role === "ERZIEHER" &&
|
||||
!user.emailVerifiedAt &&
|
||||
!!token &&
|
||||
token.identifier === user.id &&
|
||||
token.expires > now;
|
||||
} else {
|
||||
eligible &&=
|
||||
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
|
||||
(!user.lockedUntil || user.lockedUntil <= now) &&
|
||||
(job.toAdmins
|
||||
? user.role === "ADMIN"
|
||||
: user.role === "ERZIEHER" &&
|
||||
profile.userId === user.id &&
|
||||
user.staffDecisionEmails);
|
||||
}
|
||||
if (!eligible) {
|
||||
await finish({ skippedAt: now, failed: false });
|
||||
return;
|
||||
}
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base) throw new Error("App-Adresse für E-Mails fehlt.");
|
||||
const site = new URL(base);
|
||||
if (
|
||||
!["https:", "http:"].includes(site.protocol) ||
|
||||
site.username ||
|
||||
site.password
|
||||
)
|
||||
throw new Error("App-Adresse für E-Mails ist ungültig.");
|
||||
const link = new URL(job.path, site.origin).toString();
|
||||
const result = await send({
|
||||
to: user.email,
|
||||
subject: job.subject,
|
||||
idempotencyKey: `staff-email/${job.id}`,
|
||||
react: job.inviteToken
|
||||
? createElement(InviteEmail, {
|
||||
parentName: `${profile.educator.firstName} ${profile.educator.lastName}`,
|
||||
kitaName: profile.kita.name,
|
||||
inviteLink: link,
|
||||
staffAccount: true,
|
||||
})
|
||||
: createElement(StaffNotificationEmail, {
|
||||
name: user.firstName,
|
||||
kitaName: profile.kita.name,
|
||||
message: job.message,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
if (!result.success) {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
} else {
|
||||
await finish({ sentAt: new Date(), failed: false });
|
||||
sent++;
|
||||
}
|
||||
} catch {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
for (let offset = 0; offset < uniqueIds.length; offset += 4)
|
||||
await Promise.all(uniqueIds.slice(offset, offset + 4).map(deliver));
|
||||
return { sent, failed };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,820 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { after, test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { UserRole } from "@prisma/client";
|
||||
import { render } from "@react-email/render";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
import {
|
||||
executeAlarmCommand,
|
||||
respondToAlarm,
|
||||
getPublicAlarm,
|
||||
getAlarmFeed,
|
||||
processAlarmDeadlines,
|
||||
importLegacyAlarms,
|
||||
assertAssignmentNotAlarmed,
|
||||
type AlarmActor,
|
||||
} from "../src/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "../src/lib/alarms/email";
|
||||
import { authorizeAlarmJob } from "../src/lib/alarms/job";
|
||||
|
||||
|
||||
import {
|
||||
berlinInstant,
|
||||
endOfAlarm,
|
||||
alarmLabel,
|
||||
} from "../src/lib/alarms/format";
|
||||
import { dateOnly } from "../src/lib/staff/calculations";
|
||||
|
||||
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||
if (
|
||||
db.hostname !== "127.0.0.1" ||
|
||||
db.port !== "5433" ||
|
||||
db.pathname !== "/kita_security_test"
|
||||
)
|
||||
throw new Error(
|
||||
"Alarmtests benötigen die isolierte Datenbank 127.0.0.1:5433/kita_security_test.",
|
||||
);
|
||||
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||
let testUser: { id: string; role: UserRole; kitaId: string; familyId: string } | null = null;
|
||||
const testSession = () => { if (!testUser) throw new Error('No test session'); return { user: testUser }; };
|
||||
mock.module('../src/lib/auth-utils.ts', { namedExports: {
|
||||
requireKitaSession: async () => testSession(),
|
||||
requireRole: async (roles: UserRole[]) => { const session = testSession(); if (!roles.includes(session.user.role)) throw new Error('Forbidden'); return session; },
|
||||
} });
|
||||
const loadAfterMocks = createRequire(resolve(process.cwd(), 'tests/notdienst-alarm.test.ts'));
|
||||
const { updateAssignmentAction } = loadAfterMocks('../src/app/dashboard/notdienst/plan/actions') as typeof import('../src/app/dashboard/notdienst/plan/actions');
|
||||
const { createNotdienstSwapRequest, acceptNotdienstSwapRequest } = loadAfterMocks('../src/app/dashboard/kalender/actions') as typeof import('../src/app/dashboard/kalender/actions');
|
||||
|
||||
const tenants: string[] = [];
|
||||
after(async () => {
|
||||
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
const date = "2090-01-09",
|
||||
now = berlinInstant(date, 480);
|
||||
async function fixture() {
|
||||
const kita = await prisma.kita.create({
|
||||
data: { name: "Alarm-Testkita", slug: `alarm-test-${randomUUID()}` },
|
||||
});
|
||||
tenants.push(kita.id);
|
||||
const family = await prisma.family.create({
|
||||
data: { kitaId: kita.id, name: "Testfamilie" },
|
||||
});
|
||||
const otherFamily = await prisma.family.create({
|
||||
data: { kitaId: kita.id, name: "Anderer Haushalt" },
|
||||
});
|
||||
const makeUser = (role: UserRole, familyId?: string) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
role,
|
||||
familyId,
|
||||
email: `${randomUUID()}@alarm-test.invalid`,
|
||||
firstName: "Test",
|
||||
lastName: role,
|
||||
passwordHash: "test-only",
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const adminUser = await makeUser("ADMIN"),
|
||||
coordUser = await makeUser("KOORDINATOR"),
|
||||
staffUser = await makeUser("ERZIEHER"),
|
||||
p1 = await makeUser("ELTERN", family.id),
|
||||
p2 = await makeUser("ELTERN", family.id),
|
||||
outsider = await makeUser("ELTERN", otherFamily.id);
|
||||
const actor = (u: typeof p1): AlarmActor => ({
|
||||
id: u.id,
|
||||
kitaId: kita.id,
|
||||
role: u.role,
|
||||
});
|
||||
const admin = actor(adminUser),
|
||||
coord = actor(coordUser),
|
||||
staff = actor(staffUser),
|
||||
parent = actor(p1),
|
||||
otherParent = actor(outsider);
|
||||
const child = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: family.id,
|
||||
firstName: "Testkind",
|
||||
lastName: "Testfamilie",
|
||||
},
|
||||
});
|
||||
const educator = await prisma.educator.create({
|
||||
data: { kitaId: kita.id, firstName: "Interne", lastName: "Fachkraft" },
|
||||
});
|
||||
const plan = await prisma.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
year: 2090,
|
||||
month: 1,
|
||||
status: "PUBLISHED",
|
||||
publishedAt: now,
|
||||
createdById: admin.id,
|
||||
},
|
||||
});
|
||||
const assignment = await prisma.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
planId: plan.id,
|
||||
childId: child.id,
|
||||
date: dateOnly(date),
|
||||
},
|
||||
});
|
||||
const create = (
|
||||
data: Record<string, unknown> = {},
|
||||
who = admin,
|
||||
time = now,
|
||||
) =>
|
||||
executeAlarmCommand(
|
||||
who,
|
||||
"create",
|
||||
{ assignmentId: assignment.id, wholeDay: true, ...data },
|
||||
time,
|
||||
);
|
||||
const command = (
|
||||
name: string,
|
||||
alarmId: string,
|
||||
data: Record<string, unknown> = {},
|
||||
who = coord,
|
||||
time = now,
|
||||
) => executeAlarmCommand(who, name, { alarmId, ...data }, time);
|
||||
const recipients = async (alarmId: string) => {
|
||||
const rows = await prisma.notdienstAlarmRecipient.findMany({ where: { alarmId }, orderBy: { userId: "asc" } });
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({ where: { alarmId, kind: "REQUEST" } });
|
||||
return rows.map(row => ({ ...row, token: jobs.find(job => job.userId === row.userId)?.path.split("/").pop() ?? row.token }));
|
||||
};
|
||||
return {
|
||||
kita,
|
||||
family,
|
||||
otherFamily,
|
||||
adminUser,
|
||||
coordUser,
|
||||
admin,
|
||||
coord,
|
||||
staff,
|
||||
parent,
|
||||
otherParent,
|
||||
p1,
|
||||
p2,
|
||||
outsider,
|
||||
child,
|
||||
educator,
|
||||
plan,
|
||||
assignment,
|
||||
create,
|
||||
command,
|
||||
recipients,
|
||||
};
|
||||
}
|
||||
|
||||
test("Hinweise können nur vom eigenen Empfänger gelesen werden", async () => {
|
||||
const f = await fixture();
|
||||
const alarm = await f.create();
|
||||
const notice = await prisma.notdienstAlarmNotification.findFirstOrThrow({
|
||||
where: { alarmId: alarm.alarmId, userId: f.p1.id },
|
||||
});
|
||||
await executeAlarmCommand(
|
||||
f.otherParent,
|
||||
"readNotice",
|
||||
{ id: notice.id },
|
||||
now,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarmNotification.findUniqueOrThrow({
|
||||
where: { id: notice.id },
|
||||
})
|
||||
).readAt,
|
||||
null,
|
||||
);
|
||||
await executeAlarmCommand(f.parent, "readNotice", { id: notice.id }, now);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarmNotification.findUniqueOrThrow({
|
||||
where: { id: notice.id },
|
||||
})
|
||||
).readAt?.getTime(),
|
||||
now.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
test("Gesicherte Konten und fehlende Einwilligung verhindern Verwaltungszugriff", async () => {
|
||||
const f = await fixture();
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { privacyPolicyAcceptedAt: null },
|
||||
});
|
||||
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: {
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
emailVerifiedAt: new Date(),
|
||||
lockedUntil: new Date(Date.now() + 3600000),
|
||||
},
|
||||
});
|
||||
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||
const alarm = await f.create({}, f.coord);
|
||||
const token = (await f.recipients(alarm.alarmId!)).find(
|
||||
(r) => r.userId === f.p1.id,
|
||||
)!.token;
|
||||
await prisma.user.update({
|
||||
where: { id: f.p1.id },
|
||||
data: { lockedUntil: new Date(Date.now() + 3600000) },
|
||||
});
|
||||
assert.equal(await getPublicAlarm(token), null);
|
||||
await assert.rejects(respondToAlarm(token, true, now), /Ungültiger/);
|
||||
});
|
||||
|
||||
test("Aktive Alarme sperren manuelle Zuordnung sowie neue und bereits angefragte Tausche", async () => {
|
||||
const f = await fixture();
|
||||
const otherChild = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
familyId: f.otherFamily.id,
|
||||
firstName: "Anderes",
|
||||
lastName: "Testkind",
|
||||
},
|
||||
});
|
||||
const otherAssignment = await prisma.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
planId: f.plan.id,
|
||||
childId: otherChild.id,
|
||||
date: dateOnly("2090-01-10"),
|
||||
},
|
||||
});
|
||||
const request = await prisma.notdienstSwapRequest.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
requesterUserId: f.outsider.id,
|
||||
requesterFamilyId: f.otherFamily.id,
|
||||
targetAssignmentId: f.assignment.id,
|
||||
offeredAssignmentId: otherAssignment.id,
|
||||
},
|
||||
});
|
||||
await f.create();
|
||||
function login(actor: AlarmActor, familyId: string) { testUser = { ...actor, familyId }; }
|
||||
try {
|
||||
login(f.admin, f.family.id);
|
||||
const manual = await updateAssignmentAction(f.plan.id, date, otherChild.id);
|
||||
assert.match(manual.error ?? "", /Alarm/);
|
||||
login(f.otherParent, f.otherFamily.id);
|
||||
const newSwap = await createNotdienstSwapRequest({
|
||||
targetAssignmentId: f.assignment.id,
|
||||
offeredAssignmentId: otherAssignment.id,
|
||||
});
|
||||
assert.match(newSwap.error ?? "", /Alarmierung/);
|
||||
login(f.parent, f.family.id);
|
||||
const accept = await acceptNotdienstSwapRequest(request.id);
|
||||
assert.match(accept.error ?? "", /Alarmierung/);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstSwapRequest.findUniqueOrThrow({
|
||||
where: { id: request.id },
|
||||
})
|
||||
).status,
|
||||
"PENDING",
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAssignment.findUniqueOrThrow({
|
||||
where: { id: f.assignment.id },
|
||||
})
|
||||
).childId,
|
||||
f.child.id,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAssignment.findUniqueOrThrow({
|
||||
where: { id: otherAssignment.id },
|
||||
})
|
||||
).childId,
|
||||
otherChild.id,
|
||||
);
|
||||
} finally {
|
||||
testUser = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("Nur aktuelle Admins und Koordination derselben Kita dürfen Alarme verwalten", async () => {
|
||||
const f = await fixture(),
|
||||
foreign = await fixture();
|
||||
await assert.rejects(f.create({}, f.staff), /Kein Zugriff/);
|
||||
await assert.rejects(f.create({}, f.parent), /Kein Zugriff/);
|
||||
await assert.rejects(f.create({}, foreign.admin), /nicht gefunden/);
|
||||
await assert.rejects(
|
||||
f.create({}, { ...f.admin, kitaId: foreign.kita.id }),
|
||||
/keinen Zugriff/,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { role: "ELTERN" },
|
||||
});
|
||||
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||
const result = await f.create({}, f.coord);
|
||||
await assert.rejects(
|
||||
f.command("cancel", result.alarmId!, { reason: "Teststorno" }, f.parent),
|
||||
/Kein Zugriff/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Eingeteilter Haushalt erhält zwei persönliche Links, gemeinsame Antwort und 30 Minuten Frist", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.equal(alarm.deadline.getTime() - now.getTime(), 30 * 60_000);
|
||||
assert.equal(alarm.wholeDay, true);
|
||||
const recipients = await f.recipients(alarm.id);
|
||||
assert.deepEqual(
|
||||
recipients.map((r) => r.userId).sort(),
|
||||
[f.p1.id, f.p2.id].sort(),
|
||||
);
|
||||
assert.notEqual(recipients[0].token, recipients[1].token);
|
||||
assert.equal(result.emailIds.length, 2);
|
||||
assert.equal((await getAlarmFeed(f.parent, now)).length, 1);
|
||||
assert.equal((await getAlarmFeed(f.otherParent, now)).length, 0);
|
||||
assert.equal(await getPublicAlarm(randomUUID()), null);
|
||||
await respondToAlarm(
|
||||
recipients[0].token,
|
||||
true,
|
||||
new Date(now.getTime() + 60_000),
|
||||
);
|
||||
assert.equal(
|
||||
(await getPublicAlarm(recipients[1].token))?.status,
|
||||
"CONFIRMED",
|
||||
);
|
||||
await assert.rejects(
|
||||
respondToAlarm(recipients[1].token, false, now),
|
||||
/bereits/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Entwürfe, deaktivierte Module, vergangene Einsätze und ungültige Uhrzeiten werden abgewiesen", async () => {
|
||||
const f = await fixture();
|
||||
await prisma.notdienstPlan.update({
|
||||
where: { id: f.plan.id },
|
||||
data: { status: "DRAFT" },
|
||||
});
|
||||
await assert.rejects(f.create(), /veröffentlichen/);
|
||||
await prisma.notdienstPlan.update({
|
||||
where: { id: f.plan.id },
|
||||
data: { status: "PUBLISHED" },
|
||||
});
|
||||
await prisma.kita.update({
|
||||
where: { id: f.kita.id },
|
||||
data: { notdienstModuleEnabled: false },
|
||||
});
|
||||
await assert.rejects(f.create(), /deaktiviert/);
|
||||
await prisma.kita.update({
|
||||
where: { id: f.kita.id },
|
||||
data: { notdienstModuleEnabled: true },
|
||||
});
|
||||
await assert.rejects(
|
||||
f.create({ wholeDay: false, startMinute: 900, endMinute: 800 }),
|
||||
/Ende nach Beginn/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.create({ wholeDay: false, startMinute: 360, endMinute: 420 }),
|
||||
/beendet/,
|
||||
);
|
||||
await assert.rejects(f.create({ educatorId: randomUUID() }), /Fachkraft/);
|
||||
await assert.rejects(f.create({ replyMinutes: 0 }));
|
||||
await prisma.notdienstAssignment.update({
|
||||
where: { id: f.assignment.id },
|
||||
data: { date: dateOnly("2090-01-08") },
|
||||
});
|
||||
await assert.rejects(f.create(), /Vergangene/);
|
||||
});
|
||||
|
||||
test("Uhrzeiten gelten in Berlin und eine kurze Einsatzdauer begrenzt die Antwortfrist", async () => {
|
||||
const f = await fixture();
|
||||
const result = await f.create({
|
||||
wholeDay: false,
|
||||
startMinute: 480,
|
||||
endMinute: 495,
|
||||
});
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.equal(
|
||||
alarm.expiresAt.toISOString(),
|
||||
berlinInstant(date, 495).toISOString(),
|
||||
);
|
||||
assert.equal(alarm.deadline.toISOString(), alarm.expiresAt.toISOString());
|
||||
assert.equal(alarm.startMinute, 480);
|
||||
await assert.rejects(
|
||||
respondToAlarm(
|
||||
(await f.recipients(alarm.id))[0].token,
|
||||
true,
|
||||
alarm.expiresAt,
|
||||
),
|
||||
/abgelaufen/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Zeitumstellung und Tagesende werden unabhängig von der Serverzeitzone berechnet", () => {
|
||||
assert.equal(
|
||||
berlinInstant("2026-10-09", 480).toISOString(),
|
||||
"2026-10-09T06:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
berlinInstant("2026-12-09", 480).toISOString(),
|
||||
"2026-12-09T07:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
endOfAlarm("2026-03-29", true).toISOString(),
|
||||
"2026-03-29T22:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
endOfAlarm("2026-10-25", true).toISOString(),
|
||||
"2026-10-25T23:00:00.000Z",
|
||||
);
|
||||
assert.throws(() => berlinInstant("2026-03-29", 150), /Zeitumstellung/);
|
||||
});
|
||||
|
||||
test("Parallele Auslösung erstellt genau einen aktiven Vorgang", async () => {
|
||||
const f = await fixture();
|
||||
const results = await Promise.allSettled([f.create(), f.create()]);
|
||||
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarm.count({
|
||||
where: { activeAssignmentId: f.assignment.id },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmRecipient.count({
|
||||
where: { alarm: { kitaId: f.kita.id } },
|
||||
}),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("Gegensätzliche gleichzeitige Haushaltsantworten können nicht beide gespeichert werden", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
const recipients = await f.recipients(result.alarmId!);
|
||||
const answers = await Promise.allSettled([
|
||||
respondToAlarm(recipients[0].token, true, now),
|
||||
respondToAlarm(recipients[1].token, false, now),
|
||||
]);
|
||||
assert.equal(answers.filter((a) => a.status === "fulfilled").length, 1);
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.ok(["CONFIRMED", "DECLINED"].includes(alarm.status));
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmHistory.count({
|
||||
where: {
|
||||
alarmId: alarm.id,
|
||||
action: { in: ["Zusage des Haushalts", "Absage des Haushalts"] },
|
||||
},
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmEmail.count({
|
||||
where: { alarmId: alarm.id, kind: "MANAGER" },
|
||||
}),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fristprüfung eskaliert ohne Elternantwort genau einmal und erzeugt nur Koordinationshinweise", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
assert.equal(
|
||||
(
|
||||
await processAlarmDeadlines(
|
||||
new Date(now.getTime() + 29 * 60_000),
|
||||
f.kita.id,
|
||||
)
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
const deadline = new Date(now.getTime() + 30 * 60_000);
|
||||
const results = await Promise.all([
|
||||
processAlarmDeadlines(deadline, f.kita.id),
|
||||
processAlarmDeadlines(deadline, f.kita.id),
|
||||
]);
|
||||
assert.equal(results.flat().length, 2);
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||
where: { alarmId: result.alarmId, eventKey: "deadline" },
|
||||
});
|
||||
assert.deepEqual(
|
||||
jobs.map((j) => j.userId).sort(),
|
||||
[f.admin.id, f.coord.id].sort(),
|
||||
);
|
||||
assert.equal((await processAlarmDeadlines(deadline, f.kita.id)).length, 0);
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.ok(alarm.escalatedAt);
|
||||
assert.match(alarmLabel(alarm, deadline), /Frist abgelaufen/);
|
||||
});
|
||||
|
||||
test("Rechtzeitige Zusagen verhindern Fristmeldungen; Absagen informieren sofort", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
const token = (await f.recipients(result.alarmId!))[0].token;
|
||||
const response = await respondToAlarm(token, false, now);
|
||||
assert.equal(response.emailIds.length, 2);
|
||||
assert.equal(
|
||||
(
|
||||
await processAlarmDeadlines(
|
||||
new Date(now.getTime() + 60 * 60_000),
|
||||
f.kita.id,
|
||||
)
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
})
|
||||
).status,
|
||||
"DECLINED",
|
||||
);
|
||||
const other = await fixture(),
|
||||
accepted = await other.create();
|
||||
await respondToAlarm(
|
||||
(await other.recipients(accepted.alarmId!))[0].token,
|
||||
true,
|
||||
now,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await processAlarmDeadlines(
|
||||
new Date(now.getTime() + 60 * 60_000),
|
||||
other.kita.id,
|
||||
)
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("Verspätete Zusagen benötigen Freigabe; telefonische Korrekturen bleiben intern begründet", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create({
|
||||
notes: "PRIVATE_KRANKHEITSNOTIZ",
|
||||
educatorId: f.educator.id,
|
||||
});
|
||||
const late = new Date(now.getTime() + 31 * 60_000),
|
||||
token = (await f.recipients(result.alarmId!))[0].token;
|
||||
await respondToAlarm(token, true, late);
|
||||
assert.equal((await getPublicAlarm(token))?.status, "LATE_CONFIRMATION");
|
||||
await f.command(
|
||||
"approveLate",
|
||||
result.alarmId!,
|
||||
{ reason: "PRIVATE_FREIGABE" },
|
||||
f.coord,
|
||||
late,
|
||||
);
|
||||
assert.equal((await getPublicAlarm(token))?.status, "CONFIRMED");
|
||||
await f.command(
|
||||
"recordResponse",
|
||||
result.alarmId!,
|
||||
{ approve: false, reason: "PRIVATE_TELEFONNOTIZ" },
|
||||
f.admin,
|
||||
late,
|
||||
);
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.equal(alarm.status, "DECLINED");
|
||||
assert.equal(alarm.responseSource, "PHONE");
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||
where: { alarmId: alarm.id },
|
||||
});
|
||||
assert.ok(
|
||||
jobs.every(
|
||||
(job) =>
|
||||
!job.message.includes("PRIVATE_") &&
|
||||
!job.message.includes("Interne Fachkraft"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("Stornierung invalidiert alte Links, informiert den Haushalt und erlaubt neue Alarmierung", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
await assert.rejects(
|
||||
assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id),
|
||||
/aktiver Alarm/,
|
||||
);
|
||||
const token = (await f.recipients(result.alarmId!))[0].token;
|
||||
const cancelled = await f.command("cancel", result.alarmId!, {
|
||||
reason: "PRIVATE_STORNIERUNG",
|
||||
});
|
||||
assert.equal(cancelled.emailIds.length, 2);
|
||||
await assert.rejects(respondToAlarm(token, true, now), /storniert/);
|
||||
await assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id);
|
||||
const renewed = await f.create();
|
||||
assert.notEqual(renewed.alarmId, result.alarmId);
|
||||
assert.equal((await getPublicAlarm(token))?.status, "CANCELLED");
|
||||
let requests = 0;
|
||||
await deliverAlarmEmails(
|
||||
result.emailIds,
|
||||
async () => {
|
||||
requests++;
|
||||
return { success: true };
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test("Versandfehler stoppen keine anderen Empfänger; erneuter Versand lässt Frist unverändert", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
let calls = 0;
|
||||
const messages: string[] = [];
|
||||
const delivery = await deliverAlarmEmails(
|
||||
result.emailIds,
|
||||
async (input) => {
|
||||
const number = ++calls;
|
||||
messages.push(await render(input.react));
|
||||
assert.ok(input.idempotencyKey?.startsWith("notdienst-alarm/"));
|
||||
return number === 1
|
||||
? { success: false, error: "Testausfall" }
|
||||
: { success: true };
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(delivery.failed, 1);
|
||||
assert.equal(delivery.sent, 1);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(html) => html.includes("2090-01-09") && html.includes("Ganzer Kita-Tag"),
|
||||
),
|
||||
);
|
||||
const original = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
const retry = await f.command("retry", original.id);
|
||||
assert.equal(retry.emailIds.length, 1);
|
||||
assert.equal(
|
||||
(
|
||||
await deliverAlarmEmails(
|
||||
retry.emailIds,
|
||||
async () => ({ success: true }),
|
||||
now,
|
||||
)
|
||||
).sent,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: original.id },
|
||||
})
|
||||
).deadline.getTime(),
|
||||
original.deadline.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
test("Paralleler Versand verwendet Aufträge einmal; geänderte Haushalte erhalten keine Anfrage", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return { success: true as const };
|
||||
};
|
||||
await Promise.all([
|
||||
deliverAlarmEmails(result.emailIds, send, now),
|
||||
deliverAlarmEmails(result.emailIds, send, now),
|
||||
]);
|
||||
assert.equal(calls, 2);
|
||||
const next = await fixture(),
|
||||
queued = await next.create();
|
||||
await prisma.user.update({
|
||||
where: { id: next.p1.id },
|
||||
data: { familyId: next.otherFamily.id },
|
||||
});
|
||||
const token = (await next.recipients(queued.alarmId!)).find(
|
||||
(r) => r.userId === next.p1.id,
|
||||
)!.token;
|
||||
assert.equal(await getPublicAlarm(token), null);
|
||||
const sent = await deliverAlarmEmails(queued.emailIds, send, now);
|
||||
assert.equal(sent.sent, 1);
|
||||
});
|
||||
|
||||
test("Abgelaufene Tokens können nicht antworten und abgelaufene Anfragen werden nicht versendet", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create({
|
||||
wholeDay: false,
|
||||
startMinute: 480,
|
||||
endMinute: 540,
|
||||
});
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
await assert.rejects(
|
||||
respondToAlarm(
|
||||
(await f.recipients(alarm.id))[0].token,
|
||||
true,
|
||||
alarm.expiresAt,
|
||||
),
|
||||
/abgelaufen/,
|
||||
);
|
||||
let calls = 0;
|
||||
await deliverAlarmEmails(
|
||||
result.emailIds,
|
||||
async () => {
|
||||
calls++;
|
||||
return { success: true };
|
||||
},
|
||||
alarm.expiresAt,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
await processAlarmDeadlines(alarm.expiresAt, f.kita.id);
|
||||
assert.equal(
|
||||
(await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: alarm.id } }))
|
||||
.activeAssignmentId,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("Bisherige Einzelalarme werden vorsichtig übernommen und Originalstatus bleibt erhalten", async () => {
|
||||
const f = await fixture();
|
||||
for (const [user, status] of [
|
||||
[f.p1, "CONFIRMED"],
|
||||
[f.p2, "PENDING"],
|
||||
] as const)
|
||||
await prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
assignmentId: f.assignment.id,
|
||||
parentUserId: user.id,
|
||||
confirmationToken: randomUUID(),
|
||||
status,
|
||||
triggeredAt: now,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id),
|
||||
/aktiver Alarm/,
|
||||
);
|
||||
assert.equal(await importLegacyAlarms(f.kita.id), 1);
|
||||
assert.equal(await importLegacyAlarms(f.kita.id), 0);
|
||||
const alarm = await prisma.notdienstAlarm.findFirstOrThrow({
|
||||
where: { kitaId: f.kita.id },
|
||||
});
|
||||
assert.equal(alarm.status, "REVIEW_REQUIRED");
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmEmail.count({ where: { alarmId: alarm.id } }),
|
||||
0,
|
||||
);
|
||||
const legacy = await prisma.notdienstAlert.findMany({
|
||||
where: { kitaId: f.kita.id },
|
||||
});
|
||||
assert.deepEqual(legacy.map((l) => l.status).sort(), [
|
||||
"CONFIRMED",
|
||||
"PENDING",
|
||||
]);
|
||||
assert.equal(
|
||||
(await getPublicAlarm(legacy[0].confirmationToken))?.status,
|
||||
"REVIEW_REQUIRED",
|
||||
);
|
||||
});
|
||||
|
||||
test("Gültige alte Links werden gemeinsam übernommen; unzuständige Konten können sie nicht benutzen", async () => {
|
||||
const f = await fixture();
|
||||
const tokens = [randomUUID(), randomUUID()];
|
||||
for (const [index, user] of [f.p1, f.p2].entries())
|
||||
await prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
assignmentId: f.assignment.id,
|
||||
parentUserId: user.id,
|
||||
confirmationToken: tokens[index],
|
||||
triggeredAt: now,
|
||||
},
|
||||
});
|
||||
assert.equal((await getPublicAlarm(tokens[0]))?.status, "PENDING");
|
||||
await respondToAlarm(tokens[0], true, now);
|
||||
assert.equal((await getPublicAlarm(tokens[1]))?.status, "CONFIRMED");
|
||||
await prisma.user.update({
|
||||
where: { id: f.p2.id },
|
||||
data: { role: "ERZIEHER" },
|
||||
});
|
||||
assert.equal(await getPublicAlarm(tokens[1]), null);
|
||||
await assert.rejects(respondToAlarm(tokens[1], false, now), /Ungültiger/);
|
||||
});
|
||||
|
||||
test("Jobzugriff benötigt ein eigenes langes Secret und validiert den Bearer-Header", () => {
|
||||
const secret = "test-only-notdienst-alarm-secret-0123456789";
|
||||
assert.equal(authorizeAlarmJob(null, ""), 503);
|
||||
assert.equal(authorizeAlarmJob(null, secret), 401);
|
||||
assert.equal(authorizeAlarmJob(`Bearer ${secret}x`, secret), 401);
|
||||
assert.equal(authorizeAlarmJob(`Bearer ${secret}`, secret), 200);
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mock, test } from "node:test";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AnnouncementAudience,
|
||||
EventParticipationStatus as S,
|
||||
PrismaClient,
|
||||
RsvpMode,
|
||||
TerminStatus,
|
||||
TerminType,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
|
||||
// No database work without an explicitly supplied local test database.
|
||||
test(
|
||||
"Elternabläufe gegen PostgreSQL",
|
||||
{ skip: !process.env.TEST_DATABASE_URL },
|
||||
async (t) => {
|
||||
const url = new URL(process.env.TEST_DATABASE_URL!);
|
||||
assert.ok(
|
||||
["127.0.0.1", "localhost"].includes(url.hostname),
|
||||
"Nur lokale Testdatenbanken sind erlaubt",
|
||||
);
|
||||
assert.ok(
|
||||
/test|parent_flows/.test(url.pathname),
|
||||
"Der Datenbankname muss ausdrücklich eine Testdatenbank kennzeichnen",
|
||||
);
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
const db = new PrismaClient();
|
||||
const id = randomUUID();
|
||||
const kitaId = `parent-test-${id}`;
|
||||
const otherKitaId = `other-test-${id}`;
|
||||
let user = {
|
||||
id: `parent-a-${id}`,
|
||||
kitaId,
|
||||
familyId: `family-${id}`,
|
||||
role: UserRole.ELTERN as UserRole,
|
||||
};
|
||||
const moduleMock = mock as typeof mock & {
|
||||
module: (
|
||||
specifier: string,
|
||||
options: { namedExports: Record<string, unknown> },
|
||||
) => unknown;
|
||||
};
|
||||
moduleMock.module("../src/lib/auth-utils.ts", {
|
||||
namedExports: {
|
||||
requireKitaSession: async () => ({ user }),
|
||||
getKitaSession: async () => ({ user }),
|
||||
requireRole: async (roles: UserRole[]) => {
|
||||
assert.ok(roles.includes(user.role));
|
||||
return { user };
|
||||
},
|
||||
},
|
||||
});
|
||||
moduleMock.module("../src/auth.ts", {
|
||||
namedExports: { auth: async () => ({ user }) },
|
||||
});
|
||||
moduleMock.module("next/cache", {
|
||||
namedExports: { revalidatePath: () => {} },
|
||||
});
|
||||
const loadAfterMocks = createRequire(
|
||||
resolve("tests/parent-flows.integration.test.ts"),
|
||||
);
|
||||
const { submitEventRsvp } = loadAfterMocks(
|
||||
"../src/actions/rsvp",
|
||||
) as typeof import("../src/actions/rsvp");
|
||||
const { updateTerminAdmin } = loadAfterMocks(
|
||||
"../src/app/dashboard/kalender/actions",
|
||||
) as typeof import("../src/app/dashboard/kalender/actions");
|
||||
const { createAnnouncement, markAnnouncementRead } = loadAfterMocks(
|
||||
"../src/actions/announcements",
|
||||
) as typeof import("../src/actions/announcements");
|
||||
const { GET: getAttachment } = loadAfterMocks(
|
||||
"../src/app/api/announcements/attachments/[id]/route",
|
||||
) as typeof import("../src/app/api/announcements/attachments/[id]/route");
|
||||
const familyId = user.familyId;
|
||||
const startDate = new Date(Date.now() + 86400000 * 3);
|
||||
const endDate = new Date(startDate.getTime() + 3600000);
|
||||
const eventBase = {
|
||||
kitaId,
|
||||
title: "Testtermin",
|
||||
type: TerminType.SONSTIGES,
|
||||
status: TerminStatus.CONFIRMED,
|
||||
startDate,
|
||||
endDate,
|
||||
requiresRsvp: true,
|
||||
};
|
||||
const updateBase = {
|
||||
title: "Testtermin geändert",
|
||||
type: TerminType.SONSTIGES,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
};
|
||||
let teacherAnnouncementId = "";
|
||||
try {
|
||||
await db.kita.create({
|
||||
data: { id: kitaId, name: "Elternabläufe Test", slug: kitaId },
|
||||
});
|
||||
await db.kita.create({
|
||||
data: { id: otherKitaId, name: "Andere Kita", slug: otherKitaId },
|
||||
});
|
||||
await db.family.create({
|
||||
data: { id: familyId, kitaId, name: "Testfamilie" },
|
||||
});
|
||||
await db.family.create({
|
||||
data: {
|
||||
id: `other-family-${id}`,
|
||||
kitaId: otherKitaId,
|
||||
name: "Fremde Familie",
|
||||
},
|
||||
});
|
||||
await db.user.create({
|
||||
data: {
|
||||
id: user.id,
|
||||
kitaId,
|
||||
familyId,
|
||||
email: `${id}@example.test`,
|
||||
passwordHash: "test",
|
||||
firstName: "Test",
|
||||
lastName: "Eltern",
|
||||
},
|
||||
});
|
||||
const children = await Promise.all(
|
||||
["a", "b"].map((suffix) =>
|
||||
db.child.create({
|
||||
data: {
|
||||
id: `${suffix}-${id}`,
|
||||
kitaId,
|
||||
familyId,
|
||||
firstName: suffix,
|
||||
lastName: "Test",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const foreign = await db.child.create({
|
||||
data: {
|
||||
kitaId: otherKitaId,
|
||||
familyId: `other-family-${id}`,
|
||||
firstName: "Fremd",
|
||||
lastName: "Test",
|
||||
},
|
||||
});
|
||||
const inactive = await db.child.create({
|
||||
data: {
|
||||
kitaId,
|
||||
familyId,
|
||||
firstName: "Inaktiv",
|
||||
lastName: "Test",
|
||||
active: false,
|
||||
},
|
||||
});
|
||||
const familyEvent = await db.termin.create({
|
||||
data: { ...eventBase, rsvpMode: RsvpMode.FAMILY },
|
||||
});
|
||||
const childEvent = await db.termin.create({
|
||||
data: {
|
||||
...eventBase,
|
||||
rsvpMode: RsvpMode.CHILD,
|
||||
mitbringselListEnabled: true,
|
||||
participantInfo: "Nur Teilnehmende",
|
||||
},
|
||||
});
|
||||
const familyPayload = {
|
||||
eventId: familyEvent.id,
|
||||
mode: RsvpMode.FAMILY,
|
||||
status: S.ATTENDING,
|
||||
adultCount: 2,
|
||||
};
|
||||
const childPayload = {
|
||||
eventId: childEvent.id,
|
||||
mode: RsvpMode.CHILD,
|
||||
responses: [
|
||||
{ childId: children[0].id, status: S.ATTENDING },
|
||||
{ childId: children[1].id, status: S.NOT_ATTENDING },
|
||||
],
|
||||
};
|
||||
|
||||
await t.test(
|
||||
"Zwei Elternkonten ändern eine gemeinsame Familienantwort",
|
||||
async () => {
|
||||
assert.equal((await submitEventRsvp(familyPayload)).success, true);
|
||||
user = { ...user, id: `parent-b-${id}` };
|
||||
assert.equal(
|
||||
(await submitEventRsvp({ ...familyPayload, adultCount: 3 }))
|
||||
.success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await db.familyEventParticipation.count({
|
||||
where: { eventId: familyEvent.id },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.familyEventParticipation.findFirstOrThrow({
|
||||
where: { eventId: familyEvent.id },
|
||||
})
|
||||
).adultCount,
|
||||
3,
|
||||
);
|
||||
assert.ok(
|
||||
(await submitEventRsvp({ ...familyPayload, adultCount: 0 })).error,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await submitEventRsvp({
|
||||
...familyPayload,
|
||||
status: S.NOT_ATTENDING,
|
||||
adultCount: 0,
|
||||
})
|
||||
).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.familyEventParticipation.findFirstOrThrow({
|
||||
where: { eventId: familyEvent.id },
|
||||
})
|
||||
).adultCount,
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Alle Kinder werden gemeinsam gespeichert, fremde/inaktive Kinder abgewiesen",
|
||||
async () => {
|
||||
assert.equal((await submitEventRsvp(childPayload)).success, true);
|
||||
for (const childId of [foreign.id, inactive.id]) {
|
||||
assert.ok(
|
||||
(
|
||||
await submitEventRsvp({
|
||||
...childPayload,
|
||||
responses: [
|
||||
{ childId: children[0].id, status: S.NOT_ATTENDING },
|
||||
{ childId, status: S.ATTENDING },
|
||||
],
|
||||
})
|
||||
).error,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
(
|
||||
await submitEventRsvp({
|
||||
...childPayload,
|
||||
responses: [childPayload.responses[0]],
|
||||
})
|
||||
).error,
|
||||
);
|
||||
const rows = await db.eventParticipation.findMany({
|
||||
where: { eventId: childEvent.id },
|
||||
orderBy: { childId: "asc" },
|
||||
});
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].status, S.ATTENDING);
|
||||
assert.equal(rows[1].status, S.NOT_ATTENDING);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Falsche Antwortart und fremde Kita können nicht beschrieben werden",
|
||||
async () => {
|
||||
assert.ok(
|
||||
(
|
||||
await submitEventRsvp({
|
||||
...familyPayload,
|
||||
eventId: childEvent.id,
|
||||
})
|
||||
).error,
|
||||
);
|
||||
user = { ...user, kitaId: otherKitaId };
|
||||
assert.ok((await submitEventRsvp(familyPayload)).error);
|
||||
user = { ...user, kitaId };
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Frist, Absage und deaktiviertes Terminmodul verhindern Antworten",
|
||||
async () => {
|
||||
await db.termin.update({
|
||||
where: { id: familyEvent.id },
|
||||
data: { rsvpDeadline: new Date(Date.now() - 1000) },
|
||||
});
|
||||
assert.ok((await submitEventRsvp(familyPayload)).error);
|
||||
await db.termin.update({
|
||||
where: { id: familyEvent.id },
|
||||
data: { rsvpDeadline: null, status: TerminStatus.CANCELLED },
|
||||
});
|
||||
assert.ok((await submitEventRsvp(familyPayload)).error);
|
||||
await db.termin.update({
|
||||
where: { id: familyEvent.id },
|
||||
data: { status: TerminStatus.CONFIRMED },
|
||||
});
|
||||
await db.kita.update({
|
||||
where: { id: kitaId },
|
||||
data: { terminModuleEnabled: false },
|
||||
});
|
||||
assert.ok((await submitEventRsvp(familyPayload)).error);
|
||||
await db.kita.update({
|
||||
where: { id: kitaId },
|
||||
data: { terminModuleEnabled: true },
|
||||
});
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Normale Terminänderung erhält RSVP-Felder; Antwortart ist nach Antwort gesperrt",
|
||||
async () => {
|
||||
user = { ...user, role: UserRole.ADMIN };
|
||||
assert.equal(
|
||||
(await updateTerminAdmin(childEvent.id, updateBase)).success,
|
||||
true,
|
||||
);
|
||||
const event = await db.termin.findUniqueOrThrow({
|
||||
where: { id: childEvent.id },
|
||||
});
|
||||
assert.equal(event.requiresRsvp, true);
|
||||
assert.equal(event.mitbringselListEnabled, true);
|
||||
assert.equal(event.participantInfo, "Nur Teilnehmende");
|
||||
assert.ok(
|
||||
(
|
||||
await updateTerminAdmin(childEvent.id, {
|
||||
...updateBase,
|
||||
rsvpMode: RsvpMode.FAMILY,
|
||||
})
|
||||
).error,
|
||||
);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Gleichzeitige erste Antwort und Modusänderung bleiben widerspruchsfrei",
|
||||
async () => {
|
||||
const event = await db.termin.create({
|
||||
data: { ...eventBase, rsvpMode: RsvpMode.FAMILY },
|
||||
});
|
||||
await Promise.all([
|
||||
submitEventRsvp({ ...familyPayload, eventId: event.id }),
|
||||
updateTerminAdmin(event.id, {
|
||||
...updateBase,
|
||||
rsvpMode: RsvpMode.CHILD,
|
||||
}),
|
||||
]);
|
||||
const stored = await db.termin.findUniqueOrThrow({
|
||||
where: { id: event.id },
|
||||
include: { familyParticipations: true },
|
||||
});
|
||||
assert.ok(
|
||||
stored.rsvpMode === RsvpMode.FAMILY
|
||||
? stored.familyParticipations.length === 1
|
||||
: stored.familyParticipations.length === 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Empfängergruppe wird auch ohne Mail gespeichert",
|
||||
async () => {
|
||||
user = { ...user, id: `parent-a-${id}`, role: UserRole.ADMIN };
|
||||
const form = new FormData();
|
||||
form.set("title", "Nur ErzieherInnen");
|
||||
form.set("content", "Interne Teammitteilung");
|
||||
form.set("audience", "ERZIEHER");
|
||||
assert.equal((await createAnnouncement(form)).success, true);
|
||||
const announcement = await db.announcement.findFirstOrThrow({
|
||||
where: { kitaId, title: "Nur ErzieherInnen" },
|
||||
});
|
||||
assert.equal(announcement.audience, AnnouncementAudience.ERZIEHER);
|
||||
teacherAnnouncementId = announcement.id;
|
||||
await db.attachment.create({
|
||||
data: {
|
||||
id: `teacher-attachment-${id}`,
|
||||
announcementId: announcement.id,
|
||||
fileName: "team.pdf",
|
||||
fileUrl: `/api/announcements/attachments/teacher-attachment-${id}`,
|
||||
fileType: "application/pdf",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await t.test(
|
||||
"Eltern können Teammitteilungen weder als gelesen markieren noch Anhänge abrufen",
|
||||
async () => {
|
||||
user = { ...user, role: UserRole.ELTERN };
|
||||
assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error);
|
||||
const response = await getAttachment(
|
||||
new Request("http://localhost/test"),
|
||||
{ params: Promise.resolve({ id: `teacher-attachment-${id}` }) },
|
||||
);
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(
|
||||
await db.announcementRead.count({
|
||||
where: { announcementId: teacherAnnouncementId },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
user = { ...user, role: UserRole.ERZIEHER };
|
||||
assert.equal(
|
||||
(await markAnnouncementRead(teacherAnnouncementId)).success,
|
||||
true,
|
||||
);
|
||||
user = { ...user, kitaId: otherKitaId };
|
||||
assert.ok((await markAnnouncementRead(teacherAnnouncementId)).error);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await db.kita.deleteMany({
|
||||
where: { id: { in: [kitaId, otherKitaId] } },
|
||||
});
|
||||
await db.$disconnect();
|
||||
const { prisma } = loadAfterMocks(
|
||||
"../src/lib/prisma",
|
||||
) as typeof import("../src/lib/prisma");
|
||||
await prisma.$disconnect();
|
||||
mock.restoreAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,143 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
AnnouncementAudience as A,
|
||||
EventParticipationStatus as S,
|
||||
RsvpMode,
|
||||
TerminType,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
import {
|
||||
announcementRecipientWhere,
|
||||
visibleAnnouncementAudiences,
|
||||
} from "../src/lib/announcement-access";
|
||||
import {
|
||||
hasMissingRsvp,
|
||||
rsvpSubmissionSchema,
|
||||
suggestedRsvpMode,
|
||||
type RsvpEventDto,
|
||||
} from "../src/lib/rsvp";
|
||||
|
||||
const family = {
|
||||
eventId: "event",
|
||||
mode: RsvpMode.FAMILY,
|
||||
status: S.ATTENDING,
|
||||
adultCount: 2,
|
||||
};
|
||||
|
||||
test("Familienzusage braucht eine positive ganze Erwachsenenanzahl", () => {
|
||||
assert.equal(rsvpSubmissionSchema.safeParse(family).success, true);
|
||||
for (const adultCount of [0, -1, 1.5, 2147483648, NaN]) {
|
||||
assert.equal(
|
||||
rsvpSubmissionSchema.safeParse({ ...family, adultCount }).success,
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("Familienabsage setzt Erwachsenenanzahl auf null", () => {
|
||||
assert.equal(
|
||||
rsvpSubmissionSchema.safeParse({
|
||||
...family,
|
||||
status: S.NOT_ATTENDING,
|
||||
adultCount: 0,
|
||||
}).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
rsvpSubmissionSchema.safeParse({ ...family, status: S.NOT_ATTENDING })
|
||||
.success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("Kinderantworten dürfen verschiedene Zusagen enthalten, keine Duplikate oder leere Liste", () => {
|
||||
const payload = {
|
||||
eventId: "event",
|
||||
mode: RsvpMode.CHILD,
|
||||
responses: [
|
||||
{ childId: "a", status: S.ATTENDING },
|
||||
{ childId: "b", status: S.NOT_ATTENDING },
|
||||
],
|
||||
};
|
||||
assert.equal(rsvpSubmissionSchema.safeParse(payload).success, true);
|
||||
assert.equal(
|
||||
rsvpSubmissionSchema.safeParse({ ...payload, responses: [] }).success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
rsvpSubmissionSchema.safeParse({
|
||||
...payload,
|
||||
responses: [payload.responses[0], payload.responses[0]],
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("Eltern sehen Elternmeldungen und gemeinsame Meldungen", () => {
|
||||
assert.deepEqual(
|
||||
visibleAnnouncementAudiences({ role: UserRole.ELTERN, familyId: "f" }),
|
||||
[A.ALLE, A.ELTERN],
|
||||
);
|
||||
assert.deepEqual(
|
||||
visibleAnnouncementAudiences({ role: UserRole.KOORDINATOR, familyId: "f" }),
|
||||
[A.ALLE, A.ELTERN],
|
||||
);
|
||||
});
|
||||
test("Erzieherkonto erhält auch mit Haushaltszuordnung keine Elternmeldungen", () => {
|
||||
assert.deepEqual(
|
||||
visibleAnnouncementAudiences({ role: UserRole.ERZIEHER, familyId: "f" }),
|
||||
[A.ALLE, A.ERZIEHER],
|
||||
);
|
||||
});
|
||||
test("Admin darf alle Meldungen verwalten", () => {
|
||||
assert.deepEqual(
|
||||
new Set(visibleAnnouncementAudiences({ role: UserRole.ADMIN })),
|
||||
new Set(Object.values(A)),
|
||||
);
|
||||
});
|
||||
test("E-Mail-Gruppen schließen Erzieher aus Elternempfängern aus", () => {
|
||||
assert.deepEqual(announcementRecipientWhere(A.ERZIEHER), {
|
||||
role: UserRole.ERZIEHER,
|
||||
});
|
||||
assert.deepEqual(announcementRecipientWhere(A.ELTERN), {
|
||||
role: { not: UserRole.ERZIEHER },
|
||||
OR: [{ familyId: { not: null } }, { role: UserRole.ELTERN }],
|
||||
});
|
||||
});
|
||||
test("Die Rückmeldekarte verschwindet erst, wenn jedes Kind geantwortet hat", () => {
|
||||
const event: RsvpEventDto = {
|
||||
id: "e",
|
||||
title: "Ausflug",
|
||||
startDate: "2030-01-01",
|
||||
rsvpDeadline: null,
|
||||
mode: RsvpMode.CHILD,
|
||||
canReply: true,
|
||||
familyResponse: null,
|
||||
children: [
|
||||
{ id: "a", name: "A", status: S.ATTENDING },
|
||||
{ id: "b", name: "B", status: null },
|
||||
],
|
||||
};
|
||||
assert.equal(hasMissingRsvp(event), true);
|
||||
assert.equal(
|
||||
hasMissingRsvp({
|
||||
...event,
|
||||
children: event.children.map((child) => ({
|
||||
...child,
|
||||
status: S.NOT_ATTENDING,
|
||||
})),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(hasMissingRsvp({ ...event, mode: RsvpMode.FAMILY }), true);
|
||||
assert.equal(
|
||||
hasMissingRsvp({
|
||||
...event,
|
||||
mode: RsvpMode.FAMILY,
|
||||
familyResponse: { status: S.NOT_ATTENDING, adultCount: 0 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("Antwortart wird passend zur Terminart vorgeschlagen", () => {
|
||||
assert.equal(suggestedRsvpMode(TerminType.ELTERNABEND), RsvpMode.FAMILY);
|
||||
assert.equal(suggestedRsvpMode(TerminType.SONSTIGES), RsvpMode.CHILD);
|
||||
});
|
||||
+29
-6
@@ -23,6 +23,7 @@ function loader(overrides = {}) {
|
||||
}).outputText;
|
||||
const req = spec => {
|
||||
if (Object.hasOwn(overrides, spec)) return overrides[spec];
|
||||
if (spec === 'server-only') return {};
|
||||
if (spec === '@/lib/prisma') return { prisma };
|
||||
if (spec === 'next/headers') return { headers: async () => new Headers() };
|
||||
if (spec === 'next/cache') return { revalidatePath() {} };
|
||||
@@ -38,6 +39,7 @@ function loader(overrides = {}) {
|
||||
}
|
||||
return load;
|
||||
}
|
||||
process.env.AUTH_URL = 'http://127.0.0.1:3008';
|
||||
let prisma, kitaA, kitaB, familyA, familyB, parentA, parentB, adminA, founder, superadmin, childA, childB, passHash;
|
||||
const prefix = `security-${randomUUID()}`;
|
||||
const emails = [];
|
||||
@@ -255,14 +257,14 @@ test('participant secrets are removed from props delivered to unauthorized brows
|
||||
assert.equal(JSON.stringify(await page(parentB)).includes(secret), false);
|
||||
});
|
||||
test('alarm links expire and concurrent confirmations consume the token only once', async () => {
|
||||
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
|
||||
const load = loader({ '@/lib/mail': emailMock }); const { newToken, hashToken } = load('src/lib/tokens.ts');
|
||||
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 1, status: 'PUBLISHED' } });
|
||||
const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 86400000) } });
|
||||
const token = newToken();
|
||||
const alert = await prisma.notdienstAlert.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, parentUserId: parentA.id, confirmationToken: hashToken(token), triggeredAt: new Date(Date.now() - 48 * 3600000) } });
|
||||
const alert = await prisma.notdienstAlarm.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, activeAssignmentId: assignment.id, familyId: familyA.id, familyName: 'A', childName: 'A Test', date: assignment.date, expiresAt: new Date(Date.now() - 1000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: parentA.id, token: hashToken(token), sessionVersion: parentA.sessionVersion } } } });
|
||||
const confirm = load('src/app/alert/[token]/actions.ts').confirmAlertAction;
|
||||
assert.ok((await confirm(token)).error);
|
||||
await prisma.notdienstAlert.update({ where: { id: alert.id }, data: { triggeredAt: new Date() } });
|
||||
await prisma.notdienstAlarm.update({ where: { id: alert.id }, data: { expiresAt: new Date(Date.now() + 86400000) } });
|
||||
const results = await Promise.all([confirm(token), confirm(token)]);
|
||||
assert.equal(results.filter(result => result.success).length, 1);
|
||||
assert.ok((await confirm(token)).error);
|
||||
@@ -353,7 +355,7 @@ test('upload quota holds under concurrent writes and accepted MIME comes from fi
|
||||
|
||||
test('all supplemental database tenant constraints are present', async () => {
|
||||
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
|
||||
assert.equal(row.count,31);
|
||||
assert.equal(row.count,41);
|
||||
});
|
||||
test('role guard authenticates once and keeps role validation on that session', async () => {
|
||||
let calls = 0;
|
||||
@@ -370,9 +372,30 @@ test('parallel alarm triggers send one set of messages and skip unverified recip
|
||||
const before = emails.length;
|
||||
const results = await Promise.all([trigger(assignment.id),trigger(assignment.id)]);
|
||||
assert.equal(results.filter(result => result.success).length,1);
|
||||
const alerts = await prisma.notdienstAlert.findMany({ where: { assignmentId: assignment.id } });
|
||||
const alerts = await prisma.notdienstAlarmRecipient.findMany({ where: { alarm: { assignmentId: assignment.id } } });
|
||||
assert.ok(alerts.length > 0);
|
||||
assert.equal(alerts.some(alert => alert.parentUserId === unverified.id),false);
|
||||
assert.equal(alerts.some(alert => alert.userId === unverified.id),false);
|
||||
assert.equal(emails.slice(before).some(email => email.to === unverified.email),false);
|
||||
assert.equal(emails.length - before,alerts.length);
|
||||
});
|
||||
|
||||
|
||||
test('new alarm links reject stored hashes and stay invalid after account unlock', async () => {
|
||||
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
|
||||
const { getPublicAlarm } = load('src/lib/alarms/service.ts');
|
||||
const user = await prisma.user.create({ data: { email: `alarm-version.${prefix}@example.invalid`, firstName: 'Parent', lastName: 'Test', role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||
const token = newToken();
|
||||
await prisma.notdienstAlarm.create({ data: { kitaId: kitaA.id, familyId: familyA.id, familyName: 'A', childName: 'A', date: new Date(), expiresAt: new Date(Date.now() + 86400000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: user.id, token: hashToken(token), sessionVersion: user.sessionVersion } } } });
|
||||
assert.ok(await getPublicAlarm(token));
|
||||
assert.equal(await getPublicAlarm(hashToken(token)), null);
|
||||
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
|
||||
assert.equal(await getPublicAlarm(token), null);
|
||||
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: null } });
|
||||
assert.equal(await getPublicAlarm(token), null);
|
||||
});
|
||||
test('new staff and household event relationships reject foreign tenants in the database', async () => {
|
||||
const educator = await prisma.educator.create({ data: { kitaId: kitaA.id, firstName: 'Staff', lastName: 'Test' } });
|
||||
await assert.rejects(prisma.staffProfile.create({ data: { kitaId: kitaB.id, educatorId: educator.id, employmentStart: new Date(), trackingStart: new Date() } }));
|
||||
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Tenant event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date(Date.now() + 3600000), createdById: adminA.id } });
|
||||
await assert.rejects(prisma.familyEventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, familyId: familyB.id, status: 'ATTENDING' } }));
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,984 @@
|
||||
import { after, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { hashToken } from "../src/lib/tokens";
|
||||
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";
|
||||
import { deliverStaffEmails } from "../src/lib/staff/email";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||
|
||||
// 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 !== "5433" ||
|
||||
db.pathname !== "/kita_security_test"
|
||||
)
|
||||
throw new Error(
|
||||
"Integrationstests benötigen die isolierte Datenbank 127.0.0.1:5433/kita_security_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(),
|
||||
emailVerifiedAt: 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<string, unknown> = {},
|
||||
) =>
|
||||
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,
|
||||
};
|
||||
}
|
||||
test("E-Mail-Aufträge gehen an alle Admins derselben Kita; Versandfehler bleiben wiederholbar", async () => {
|
||||
const f = await fixture(),
|
||||
foreign = await fixture();
|
||||
const second = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
email: `${randomUUID()}@staff-test.invalid`,
|
||||
passwordHash: "test-only",
|
||||
firstName: "Zweiter",
|
||||
lastName: "Admin",
|
||||
role: "ADMIN",
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await f.run(f.staff, "leave", {
|
||||
...vacation(),
|
||||
note: "VERTRAULICHE_NOTIZ",
|
||||
});
|
||||
const jobs = await prisma.staffEmail.findMany({
|
||||
where: { id: { in: result.emailIds! } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
jobs.map((j) => j.userId).sort(),
|
||||
[f.admin.id, second.id].sort(),
|
||||
);
|
||||
assert.ok(jobs.every((j) => !j.message.includes("VERTRAULICHE_NOTIZ")));
|
||||
assert.equal((await f.run(f.other, "emailRetry")).emailIds?.length, 0);
|
||||
assert.equal(
|
||||
(await foreign.run(foreign.admin, "emailRetry")).emailIds?.length,
|
||||
0,
|
||||
);
|
||||
const failure = await deliverStaffEmails(result.emailIds!, async () => ({
|
||||
success: false,
|
||||
error: "Testausfall",
|
||||
}));
|
||||
assert.equal(failure.failed, 2);
|
||||
assert.equal(
|
||||
await prisma.staffLeave.count({ where: { profileId: f.profile.id } }),
|
||||
1,
|
||||
);
|
||||
const retry = await f.run(f.admin, "emailRetry");
|
||||
const messages: string[] = [];
|
||||
const delivered = await deliverStaffEmails(retry.emailIds!, async (input) => {
|
||||
messages.push(await render(input.react));
|
||||
assert.ok(input.idempotencyKey?.startsWith("staff-email/"));
|
||||
return { success: true, id: "test-only" };
|
||||
});
|
||||
assert.equal(delivered.sent, 2);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(html) =>
|
||||
html.includes("2026-01-05") && !html.includes("VERTRAULICHE_NOTIZ"),
|
||||
),
|
||||
);
|
||||
assert.equal((await f.run(f.admin, "emailRetry")).emailIds?.length, 0);
|
||||
});
|
||||
|
||||
test("Fachkräfte schalten nur eigene Entscheidungsmails ab; interne Hinweise bleiben erhalten", async () => {
|
||||
const f = await fixture();
|
||||
await f.run(f.staff, "leave", vacation());
|
||||
const leave = await prisma.staffLeave.findFirstOrThrow({
|
||||
where: { profileId: f.profile.id },
|
||||
});
|
||||
const decision = await f.run(f.admin, "leaveReview", {
|
||||
id: leave.id,
|
||||
approve: true,
|
||||
reason: "PRIVATE_BEGRUENDUNG",
|
||||
});
|
||||
assert.equal(decision.emailIds?.length, 1);
|
||||
const queued = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: decision.emailIds![0] },
|
||||
});
|
||||
assert.equal(queued.userId, f.staff.id);
|
||||
assert.ok(!queued.message.includes("PRIVATE_BEGRUENDUNG"));
|
||||
await f.run(f.staff, "emailPreference", { enabled: false });
|
||||
assert.equal(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: f.other.id } }))
|
||||
.staffDecisionEmails,
|
||||
true,
|
||||
);
|
||||
let calls = 0;
|
||||
await deliverStaffEmails(decision.emailIds!, async () => {
|
||||
calls++;
|
||||
return { success: true };
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
await f.run(f.staff, "leave", vacation("2026-01-06"));
|
||||
const next = await prisma.staffLeave.findFirstOrThrow({
|
||||
where: { profileId: f.profile.id, startDate: dateOnly("2026-01-06") },
|
||||
});
|
||||
const disabled = await f.run(f.admin, "leaveReview", {
|
||||
id: next.id,
|
||||
approve: false,
|
||||
reason: "Testentscheidung",
|
||||
});
|
||||
assert.equal(disabled.emailIds?.length, 0);
|
||||
assert.ok(
|
||||
(await prisma.staffNotification.count({ where: { userId: f.staff.id } })) >=
|
||||
2,
|
||||
);
|
||||
const sickness = await f.run(f.admin, "leave", {
|
||||
...vacation("2026-01-08"),
|
||||
kind: "SICKNESS",
|
||||
});
|
||||
assert.equal(sickness.emailIds?.length, 0);
|
||||
await assert.rejects(
|
||||
f.run(f.admin, "emailPreference", { enabled: false }),
|
||||
/Fachkräfte/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Arbeitswochen erzeugen getrennte Einreichungs- und Entscheidungsmails ohne private Rückmeldung", async () => {
|
||||
const f = await fixture();
|
||||
const submitted = await f.run(f.staff, "weekSubmit", {
|
||||
weekStart: "2026-01-05",
|
||||
});
|
||||
const adminJob = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: submitted.emailIds![0] },
|
||||
});
|
||||
assert.equal(adminJob.userId, f.admin.id);
|
||||
assert.ok(adminJob.path.includes("week=2026-01-05"));
|
||||
const reviewed = await f.run(f.admin, "weekReview", {
|
||||
weekStart: "2026-01-05",
|
||||
approve: false,
|
||||
reason: "PRIVATE_RUECKMELDUNG",
|
||||
});
|
||||
const job = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: reviewed.emailIds![0] },
|
||||
});
|
||||
assert.equal(job.userId, f.staff.id);
|
||||
assert.ok(job.message.includes("zur Korrektur"));
|
||||
assert.ok(!job.message.includes("PRIVATE_RUECKMELDUNG"));
|
||||
});
|
||||
|
||||
test("Erneuerte Aktivierungslinks ersetzen ausstehende alte Mails und funktionieren trotz Opt-out", async () => {
|
||||
const f = await fixture();
|
||||
await prisma.staffProfile.update({
|
||||
where: { id: f.profile.id },
|
||||
data: { userId: null },
|
||||
});
|
||||
const email = `${randomUUID()}@staff-test.invalid`;
|
||||
const old = await f.run(f.admin, "invite", { email });
|
||||
const linked = await prisma.user.findUniqueOrThrow({ where: { email } });
|
||||
await prisma.user.update({
|
||||
where: { id: linked.id },
|
||||
data: { staffDecisionEmails: false },
|
||||
});
|
||||
const renewed = await f.run(f.admin, "invite", { email });
|
||||
const html: string[] = [];
|
||||
const result = await deliverStaffEmails(
|
||||
[...old.emailIds!, ...renewed.emailIds!],
|
||||
async (input) => {
|
||||
assert.equal(input.to, email);
|
||||
html.push(await render(input.react, { plainText: true }));
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
assert.equal(result.sent, 1);
|
||||
assert.ok(html[0].includes(renewed.invite!.token));
|
||||
assert.ok(!html[0].includes(old.invite!.token));
|
||||
assert.ok(
|
||||
html[0].includes("Fachkraft-Account") && html[0].includes("sieben Tage"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Parallele Versandversuche senden einmal und beachten inzwischen entzogene Rollen", async () => {
|
||||
const f = await fixture();
|
||||
const result = await f.run(f.staff, "leave", vacation());
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return { success: true as const };
|
||||
};
|
||||
await Promise.all([
|
||||
deliverStaffEmails(result.emailIds!, send),
|
||||
deliverStaffEmails(result.emailIds!, send),
|
||||
]);
|
||||
assert.equal(calls, 1);
|
||||
await deliverStaffEmails(result.emailIds!, send);
|
||||
assert.equal(calls, 1);
|
||||
const changed = await f.run(f.staff, "leave", vacation("2026-01-06"));
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { role: "ELTERN" },
|
||||
});
|
||||
await deliverStaffEmails(changed.emailIds!, send);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
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: hashToken(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(),
|
||||
emailVerifiedAt: new Date(),
|
||||
lockedUntil: new Date(Date.now() + 60000),
|
||||
},
|
||||
});
|
||||
await assert.rejects(f.run(f.staff, "time", time()), /keinen Zugriff/);
|
||||
});
|
||||
|
||||
function onboarding(email = `${randomUUID()}@staff-test.invalid`) {
|
||||
return {
|
||||
firstName: " Neue ",
|
||||
lastName: " Fachkraft ",
|
||||
active: true,
|
||||
email,
|
||||
employmentStart: "2024-08-01",
|
||||
trackingStart: "2026-10-09",
|
||||
};
|
||||
}
|
||||
|
||||
test("Gemeinsame Anlage speichert Fachkraft, Profil, Account und wiederholbaren Mailauftrag", async () => {
|
||||
const f = await fixture();
|
||||
const input = onboarding();
|
||||
const result = await executeStaffCommand(f.admin, {
|
||||
command: "educatorCreate",
|
||||
data: input,
|
||||
});
|
||||
const educator = await prisma.educator.findUniqueOrThrow({
|
||||
where: { id: result.educatorId },
|
||||
include: { staffProfile: { include: { user: true } } },
|
||||
});
|
||||
assert.equal(educator.firstName, "Neue");
|
||||
assert.equal(educator.kitaId, f.kita.id);
|
||||
assert.equal(educator.staffProfile?.user?.email, input.email);
|
||||
assert.equal(educator.staffProfile?.user?.emailVerifiedAt, null);
|
||||
assert.equal(educator.staffProfile?.user?.role, "ERZIEHER");
|
||||
assert.equal(
|
||||
educator.staffProfile?.employmentStart.toISOString().slice(0, 10),
|
||||
"2024-08-01",
|
||||
);
|
||||
assert.equal(
|
||||
educator.staffProfile?.trackingStart.toISOString().slice(0, 10),
|
||||
"2026-10-09",
|
||||
);
|
||||
assert.equal(educator.staffProfile?.openingMinutes, 0);
|
||||
assert.ok(result.invite);
|
||||
assert.ok(
|
||||
await prisma.verificationToken.findUnique({
|
||||
where: { token: hashToken(result.invite.token) },
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await deliverStaffEmails(result.emailIds!, async () => ({
|
||||
success: false,
|
||||
error: "Testausfall",
|
||||
}))
|
||||
).failed,
|
||||
1,
|
||||
);
|
||||
assert.ok(await prisma.educator.findUnique({ where: { id: educator.id } }));
|
||||
const retry = await executeStaffCommand(f.admin, {
|
||||
command: "emailRetry",
|
||||
data: {},
|
||||
});
|
||||
assert.deepEqual(retry.emailIds, result.emailIds);
|
||||
assert.equal(
|
||||
(
|
||||
await deliverStaffEmails(retry.emailIds!, async () => ({
|
||||
success: true,
|
||||
id: "mock",
|
||||
}))
|
||||
).sent,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("Pflichtfelder und Erfassungsdatum verhindern unvollständige oder falsche Anlagen", async () => {
|
||||
const f = await fixture();
|
||||
const before = await prisma.educator.count({ where: { kitaId: f.kita.id } });
|
||||
for (const input of [
|
||||
onboarding(""),
|
||||
{ ...onboarding(), firstName: " " },
|
||||
{ ...onboarding(), trackingStart: "2024-07-31" },
|
||||
{ ...onboarding(), employmentStart: "2026-02-30" },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeStaffCommand(f.admin, { command: "educatorCreate", data: input }),
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await prisma.educator.count({ where: { kitaId: f.kita.id } }),
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
test("Doppelte oder fremde Accounts rollen die gesamte Anlage zurück", async () => {
|
||||
const f = await fixture(),
|
||||
foreign = await fixture();
|
||||
const parent = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: f.parent.id },
|
||||
});
|
||||
const before = await prisma.educator.count({ where: { kitaId: f.kita.id } });
|
||||
for (const email of [
|
||||
f.staffUser.email,
|
||||
foreign.staffUser.email,
|
||||
parent.email,
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeStaffCommand(f.admin, {
|
||||
command: "educatorCreate",
|
||||
data: onboarding(email),
|
||||
}),
|
||||
/bereits/,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await prisma.educator.count({ where: { kitaId: f.kita.id } }),
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
test("Freier aktivierter Fachkraft-Account derselben Kita wird verknüpft", async () => {
|
||||
const f = await fixture();
|
||||
const user = await prisma.user.update({
|
||||
where: { id: f.other.id },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
});
|
||||
const result = await executeStaffCommand(f.admin, {
|
||||
command: "educatorCreate",
|
||||
data: onboarding(user.email.toUpperCase()),
|
||||
});
|
||||
const profile = await prisma.staffProfile.findUniqueOrThrow({
|
||||
where: { educatorId: result.educatorId },
|
||||
});
|
||||
assert.equal(profile.userId, user.id);
|
||||
assert.equal(result.invite, undefined);
|
||||
assert.equal(result.emailIds, undefined);
|
||||
});
|
||||
|
||||
test("Bestehende Fachkraft ohne Profil erhält Zugang im Bearbeitungsablauf", async () => {
|
||||
const f = await fixture();
|
||||
const educator = await prisma.educator.create({
|
||||
data: { firstName: "Alt", lastName: "Ohne Profil", kitaId: f.kita.id },
|
||||
});
|
||||
const input = onboarding();
|
||||
await assert.rejects(
|
||||
executeStaffCommand(f.admin, {
|
||||
command: "educatorUpdate",
|
||||
data: { ...input, educatorId: educator.id, email: f.staffUser.email },
|
||||
}),
|
||||
/bereits/,
|
||||
);
|
||||
assert.equal(
|
||||
(await prisma.educator.findUniqueOrThrow({ where: { id: educator.id } }))
|
||||
.firstName,
|
||||
"Alt",
|
||||
);
|
||||
const result = await executeStaffCommand(f.admin, {
|
||||
command: "educatorUpdate",
|
||||
data: { ...input, educatorId: educator.id },
|
||||
});
|
||||
assert.equal(result.educatorId, educator.id);
|
||||
assert.ok(result.invite);
|
||||
assert.equal(
|
||||
await prisma.staffProfile.count({ where: { educatorId: educator.id } }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("Bearbeiten schützt verknüpfte Identität, Zeiten und Konten", async () => {
|
||||
const f = await fixture();
|
||||
await assert.rejects(
|
||||
executeStaffCommand(f.admin, {
|
||||
command: "educatorUpdate",
|
||||
data: {
|
||||
...onboarding(),
|
||||
educatorId: f.educator.id,
|
||||
},
|
||||
}),
|
||||
/nicht geändert/,
|
||||
);
|
||||
await executeStaffCommand(f.admin, {
|
||||
command: "educatorUpdate",
|
||||
data: {
|
||||
...onboarding(f.staffUser.email),
|
||||
firstName: "Korrigiert",
|
||||
educatorId: f.educator.id,
|
||||
employmentStart: "2025-01-01",
|
||||
trackingStart: "2025-01-01",
|
||||
},
|
||||
});
|
||||
const profile = await getStaffProfile(f.admin, f.profile.id);
|
||||
assert.equal(profile?.openingMinutes, 600);
|
||||
assert.equal(
|
||||
profile?.employmentStart.toISOString().slice(0, 10),
|
||||
"2026-01-01",
|
||||
);
|
||||
assert.equal(profile?.workPatterns.length, 1);
|
||||
assert.equal(profile?.leaveAccounts.length, 1);
|
||||
assert.equal(profile?.user?.firstName, "Korrigiert");
|
||||
});
|
||||
|
||||
test("Einladung aus Teamliste erneuert nur eigene ausstehende Aktivierung", async () => {
|
||||
const f = await fixture(),
|
||||
foreign = await fixture();
|
||||
const result = await executeStaffCommand(f.admin, {
|
||||
command: "educatorCreate",
|
||||
data: onboarding(),
|
||||
});
|
||||
for (const actor of [f.staff, f.parent, foreign.admin]) {
|
||||
await assert.rejects(
|
||||
executeStaffCommand(actor, {
|
||||
command: "educatorInvite",
|
||||
data: { educatorId: result.educatorId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const renewed = await executeStaffCommand(f.admin, {
|
||||
command: "educatorInvite",
|
||||
data: { educatorId: result.educatorId },
|
||||
});
|
||||
assert.ok(renewed.invite);
|
||||
assert.notEqual(renewed.invite.token, result.invite?.token);
|
||||
assert.equal(
|
||||
await prisma.verificationToken.count({
|
||||
where: { token: hashToken(result.invite!.token) },
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const profile = await prisma.staffProfile.findUniqueOrThrow({
|
||||
where: { educatorId: result.educatorId },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: profile.userId! },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
});
|
||||
await assert.rejects(
|
||||
executeStaffCommand(f.admin, {
|
||||
command: "educatorInvite",
|
||||
data: { educatorId: result.educatorId },
|
||||
}),
|
||||
/bereits einen Account/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Nur aktuelle Kita-Admins dürfen Fachkräfte anlegen und bearbeiten", async () => {
|
||||
const f = await fixture();
|
||||
const before = await prisma.educator.count({ where: { kitaId: f.kita.id } });
|
||||
for (const actor of [
|
||||
f.staff,
|
||||
f.parent,
|
||||
{ ...f.staff, role: UserRole.ADMIN },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeStaffCommand(actor, {
|
||||
command: "educatorCreate",
|
||||
data: onboarding(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const foreign = await fixture();
|
||||
await assert.rejects(
|
||||
executeStaffCommand(foreign.admin, {
|
||||
command: "educatorUpdate",
|
||||
data: {
|
||||
...onboarding(f.staffUser.email),
|
||||
educatorId: f.educator.id,
|
||||
},
|
||||
}),
|
||||
/nicht gefunden/,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.educator.count({ where: { kitaId: f.kita.id } }),
|
||||
before,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user