diff --git a/deploy/sql/personalbereich-email.sql b/deploy/sql/personalbereich-email.sql
new file mode 100644
index 0000000..99b96ab
--- /dev/null
+++ b/deploy/sql/personalbereich-email.sql
@@ -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;
diff --git a/deploy/sql/personalbereich.sql b/deploy/sql/personalbereich.sql
index 5314b4f..dd7ac0f 100644
--- a/deploy/sql/personalbereich.sql
+++ b/deploy/sql/personalbereich.sql
@@ -1,5 +1,5 @@
--- Einmalige additive Änderung gegen Ausgangscommit ec3f4f9.
--- Alternativ zum bestehenden prisma db push, nicht danach erneut ausführen.
+-- 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
@@ -11,6 +11,9 @@ 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,
@@ -26,6 +29,26 @@ CREATE TABLE "staff_profiles" (
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,
@@ -173,6 +196,9 @@ 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");
@@ -215,6 +241,12 @@ ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_fkey" FOR
-- 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;
diff --git a/docs/FUNKTIONSUEBERSICHT.md b/docs/FUNKTIONSUEBERSICHT.md
index 801905d..4431148 100644
--- a/docs/FUNKTIONSUEBERSICHT.md
+++ b/docs/FUNKTIONSUEBERSICHT.md
@@ -10,6 +10,8 @@ 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.
diff --git a/docs/PERSONALBEREICH.md b/docs/PERSONALBEREICH.md
index 3cdcda3..23d8bdf 100644
--- a/docs/PERSONALBEREICH.md
+++ b/docs/PERSONALBEREICH.md
@@ -1,6 +1,6 @@
# Personalbereich: Arbeitszeit, Urlaub und Zeitkonto
-Stand: 07.10.2026 · Branch `codex/personalbereich`
+Stand: 08.10.2026 · Branch `codex/personalbereich`
## Einstieg und Zuständigkeiten
@@ -14,7 +14,7 @@ Bestehende ErzieherInnen-Stammdaten bleiben erhalten. Ein Admin öffnet beim Per
4. Einen passenden ErzieherInnen-Account. Bestehende verifizierte Accounts derselben Kita können verknüpft werden. Für neue Accounts wird ein persönlicher, einmal verwendbarer Aktivierungslink mit sieben Tagen Gültigkeit erzeugt. Ein neuer Link ersetzt den alten.
5. Die örtlichen Feiertage unter **Teamkalender**. Feiertage gelten separat von den Schließtagen des allgemeinen Kalenders.
-Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Aktivierungslinks werden nur dem berechtigten Admin angezeigt und persönlich weitergegeben. Automatischer E-Mail-Versand ist in dieser Umsetzung nicht angebunden.
+Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Neue Accounts erhalten den Aktivierungslink per E-Mail; der berechtigte Admin erhält ihn zusätzlich zum Kopieren. Ein vorhandener verifizierter Account wird verknüpft, ohne eine Aktivierungsmail zu verschicken.
## Arbeitszeit und Überstunden
@@ -59,6 +59,7 @@ Das Projekt verwendet weiterhin seinen vorhandenen Ablauf mit `prisma db push`.
- 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.
@@ -71,27 +72,33 @@ Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht ver
Ergebnisse der abschließenden Prüfung:
-| Prüfung | Ergebnis |
-| --- | --- |
-| Berechnungstests | 9 bestanden. |
-| Integrationstests | 12 bestanden; Testkitas danach bereinigt. |
-| TypeScript | Erfolgreich. |
-| ESLint für sämtliche geänderten Anwendungsteile und neue Tests | Erfolgreich, ohne Warnungen. |
-| Projektweites ESLint | Die bereits vorhandenen zwei `as any`-Fehler in `src/lib/auth-utils.ts` und neun Warnungen bestehen weiterhin. |
-| Endgültiger Produktionsbuild | Erfolgreich, einschließlich `/dashboard/personal`. |
-| SQL-Skript gegen Ausgangsschema | Erfolgreich im isolierten Prüfschema; anschließend kein Unterschied zum Prisma-Schema. |
-| Browser | Admin- und ErzieherInnen-Ablauf einschließlich Freigaben erfolgreich. Mobile Ansicht und Arbeitszeitdialog ohne Seitenüberlauf geprüft. |
+| Prüfung | Ergebnis |
+| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Berechnungstests | 9 bestanden. |
+| Integrationstests | 17 bestanden; einschließlich 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.
-## E-Mail-Erweiterung zur gesonderten Freigabe
+## E-Mail-Hinweise
-Die automatische Freigabeprüfung hat neue ausgehende E-Mails mit Einladungstokens und Personaldaten abgelehnt, weil eine ausdrückliche Zustimmung für diese Daten und Ziele fehlt. Interne Hinweise und Aktivierungslinks sind umgesetzt. Für einen späteren Versand wäre folgender begrenzter Umfang vorgesehen:
+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 oder Arbeitswoche eingereicht | Account-Adressen der Kita-Admins derselben Kita | Name, Zeitraum und Hinweis auf Prüfung im geschützten Personalbereich. |
-| Entscheidung / Rückgabe | Account-Adresse der betroffenen Fachkraft | Art, Zeitraum und Status mit Link in den geschützten Personalbereich. |
+| 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. |
-Krankheitsdetails, private Notizen, Zeit- und Urlaubskontostände würden nicht per E-Mail übertragen. Hierfür wurde kein Live-Versandcode hinzugefügt und keine Nachricht verschickt.
+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.
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e76aff1..57ffc50 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -213,6 +213,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?
@@ -243,6 +244,7 @@ model User {
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
staffProfile StaffProfile?
staffNotifications StaffNotification[]
+ staffEmails StaffEmail[]
@@index([kitaId])
@@index([familyId])
@@ -348,11 +350,35 @@ model StaffProfile {
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
diff --git a/src/app/dashboard/personal/actions.ts b/src/app/dashboard/personal/actions.ts
index d537ad5..c750a31 100644
--- a/src/app/dashboard/personal/actions.ts
+++ b/src/app/dashboard/personal/actions.ts
@@ -4,11 +4,14 @@ 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 = {
error?: string;
success?: boolean;
invitePath?: string;
+ warning?: string;
+ message?: string;
};
export async function staffAction(
@@ -18,6 +21,28 @@ export async function staffAction(
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");
@@ -25,6 +50,8 @@ export async function staffAction(
// Returned only to the authenticated admin who generated this single-use link.
return {
success: true,
+ warning,
+ message,
...(result.invite
? { invitePath: `/invite/${result.invite.token}` }
: {}),
diff --git a/src/app/dashboard/personal/forms.tsx b/src/app/dashboard/personal/forms.tsx
index 5c92d92..4009ca4 100644
--- a/src/app/dashboard/personal/forms.tsx
+++ b/src/app/dashboard/personal/forms.tsx
@@ -66,6 +66,8 @@ function transform(command: string, form: FormData) {
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"),
@@ -169,7 +171,8 @@ function StaffForm({
setError(result.error);
return;
}
- toast.success("Gespeichert.");
+ if (result.warning) toast.warning(result.warning);
+ else toast.success(result.message ?? "Gespeichert.");
onSuccess?.(result);
router.refresh();
} catch (error) {
@@ -224,7 +227,8 @@ export function CommandButton({
const result = await staffAction(command, { ...data, profileId });
if (result.error) toast.error(result.error);
else {
- toast.success("Gespeichert.");
+ if (result.warning) toast.warning(result.warning);
+ else toast.success(result.message ?? "Gespeichert.");
router.refresh();
}
})
@@ -535,6 +539,29 @@ function SettingsSection({
);
}
+export function StaffEmailSettings({ enabled }: { enabled: boolean }) {
+ return (
+
+ Aktivierungslinks und Hinweise innerhalb der App bleiben davon
+ unabhängig.
+
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
- persönliche Link ist sieben Tage gültig. Ein neuer Link ersetzt
- den vorherigen.
+ persönliche Aktivierungslink wird per E-Mail versendet und ist
+ sieben Tage gültig. Ein neuer Link ersetzt den vorherigen.
+ {pendingEmails} E-Mail-Hinweise warten noch auf den Versand. +
+