feat: create and invite educators in a single form

This commit is contained in:
t.indorf
2026-10-09 21:59:04 +02:00
parent 979f795341
commit 4702bf5abb
8 changed files with 785 additions and 177 deletions
+24 -10
View File
@@ -1,20 +1,26 @@
# Personalbereich: Arbeitszeit, Urlaub und Zeitkonto
Stand: 08.10.2026 · Branch `codex/personalbereich`
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.
Bestehende ErzieherInnen-Stammdaten bleiben erhalten. Ein Admin öffnet beim Personalstamm das **Personalprofil** und hinterlegt:
Neue Fachkräfte werden unter **ErzieherInnen → ErzieherIn anlegen** in einem Formular eingerichtet:
1. Beschäftigungsbeginn, optionales Ende, Beginn der Zeiterfassung und Anfangssaldo.
2. Ein datiertes Wochenmodell mit Netto-Sollstunden für Montag bis Sonntag; 0 bedeutet frei.
3. Jahresurlaub und ausdrücklich festgelegten Übertrag, individuell für die Arbeitstage dieser Person.
4. Einen passenden ErzieherInnen-Account. Bestehende verifizierte Accounts derselben Kita können verknüpft werden. Für neue Accounts wird ein persönlicher, einmal verwendbarer Aktivierungslink mit sieben Tagen Gültigkeit erzeugt. Ein neuer Link ersetzt den alten.
5. Die örtlichen Feiertage unter **Teamkalender**. Feiertage gelten separat von den Schließtagen des allgemeinen Kalenders.
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.
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.
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
@@ -73,9 +79,9 @@ Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht ver
Ergebnisse der abschließenden Prüfung:
| Prüfung | Ergebnis |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Berechnungstests | 9 bestanden. |
| Integrationstests | 17 bestanden; einschließlich Empfänger, Opt-out, Versandfehler, Token-Erneuerung und paralleler Versandversuche. Testkitas danach bereinigt. |
| 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. |
@@ -85,6 +91,14 @@ Ergebnisse der abschließenden Prüfung:
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:
@@ -2,6 +2,7 @@
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";
@@ -30,16 +31,108 @@ 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,
@@ -54,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}`,
);
}
});
};
@@ -73,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);
@@ -103,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 />
@@ -129,21 +267,30 @@ 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"
disabled={isPending}
onClick={() => setOpenCreate(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Speichern
{isPending
? "Wird angelegt …"
: "Fachkraft anlegen und einladen"}
</Button>
</DialogFooter>
</form>
@@ -169,14 +316,15 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<TableRow>
<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>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<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]" />
@@ -191,12 +339,38 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</TableCell>
</TableRow>
) : (
educators.map((ed) => (
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"}
@@ -218,7 +392,12 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<Button
variant="ghost"
size="icon"
onClick={() => setEditingEducator(ed)}
onClick={() => {
setFormError("");
setEditingEducator(ed);
}}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} bearbeiten`}
>
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button>
@@ -244,15 +423,22 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
{/* Edit Dialog */}
<Dialog
open={!!editingEducator}
onOpenChange={(open) => !open && setEditingEducator(null)}
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));
}}
>
<DialogContent>
<form action={handleUpdate}>
<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
@@ -271,6 +457,11 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
required
/>
</div>
<AccessFields
prefix="edit"
today={today}
educator={editingEducator}
/>
<div className="flex items-center gap-2 mt-2">
<Switch
id="edit-active"
@@ -279,18 +470,28 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
/>
<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"
disabled={isPending}
onClick={() => setEditingEducator(null)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Aktualisieren
{isPending
? "Wird gespeichert …"
: editingEducator?.email
? "Aktualisieren"
: "Speichern und einladen"}
</Button>
</DialogFooter>
</form>
+11 -62
View File
@@ -6,74 +6,23 @@ import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { staffAction } from "../personal/actions";
const educatorSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
active: z.boolean().default(true),
});
// The staff service creates the person, profile, account and mail job in one transaction.
export async function createEducator(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.create({
data: {
kitaId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
return staffAction("educatorCreate", parsed.data);
}
export async function updateEducator(id: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
return staffAction("educatorUpdate", { ...parsed.data, educatorId: id });
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.update({
where: {
id,
kitaId,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
export async function resendEducatorInvitation(educatorId: string) {
return staffAction("educatorInvite", { educatorId });
}
export async function deleteEducator(id: string) {
+23 -1
View File
@@ -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" }],
});
@@ -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
View File
@@ -7,6 +7,7 @@ import { executeStaffCommand } from "@/lib/staff/service";
import { deliverStaffEmails } from "@/lib/staff/email";
export type StaffActionResult = {
educatorId?: string;
error?: string;
success?: boolean;
invitePath?: string;
@@ -50,6 +51,7 @@ export async function staffAction(
// Returned only to the authenticated admin who generated this single-use link.
return {
success: true,
educatorId: result.educatorId,
warning,
message,
...(result.invite
+2 -2
View File
@@ -634,8 +634,8 @@ export function SettingsPanel({
Link kopieren
</Button>
<p className="text-xs">
Bitte persönlich an die Fachkraft weitergeben. Es wird keine
E-Mail versendet.
Der Link wird per E-Mail versendet. Bei Bedarf kannst du ihn
zusätzlich persönlich an die Fachkraft weitergeben.
</p>
</div>
)}
+221 -70
View File
@@ -28,11 +28,123 @@ export type StaffNotice = {
toAdmins: boolean;
};
type CommandResult = {
educatorId?: string;
emailIds?: string[];
notice?: StaffNotice;
invite?: { email: string; name: string; token: string; kitaName: string };
};
const idSchema = z.string().min(1).max(100);
const educatorInput = z.object({
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
active: z.boolean().default(true),
email: z
.string()
.trim()
.max(254)
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase(),
});
const onboardingDates = z
.object({
employmentStart: dateSchema,
trackingStart: dateSchema,
})
.refine((input) => input.trackingStart >= input.employmentStart, {
message:
"Der Start der Zeiterfassung darf nicht vor dem Beschäftigungsbeginn liegen.",
});
async function inviteStaff(
tx: Prisma.TransactionClient,
actor: StaffActor,
profile: {
id: string;
kitaId: string;
userId: string | null;
user: { email: string; emailVerifiedAt: Date | null } | null;
educator: { firstName: string; lastName: string };
},
email: string,
): Promise<CommandResult> {
assertStaffAccess(actor, profile, true);
if (
profile.userId &&
(profile.user?.email !== email || profile.user.emailVerifiedAt)
)
throw new Error(
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
);
const existingUser = await tx.user.findUnique({ where: { email } });
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } },
})))
)
throw new Error(
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
);
const user =
existingUser ??
(await tx.user.create({
data: {
email,
firstName: profile.educator.firstName,
lastName: profile.educator.lastName,
passwordHash: "",
role: UserRole.ERZIEHER,
kitaId: actor.kitaId,
},
}));
await tx.staffProfile.update({
where: { id: profile.id },
data: { userId: user.id },
});
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: "Account verknüpft",
details: email,
},
});
if (user.emailVerifiedAt) return {};
const token = randomUUID();
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
await tx.verificationToken.create({
data: {
identifier: user.id,
token,
expires: new Date(Date.now() + 7 * 86400000),
},
});
const kita = await tx.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true },
});
const job = await tx.staffEmail.create({
data: {
profileId: profile.id,
userId: user.id,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
message: "Account aktivieren",
path: `/invite/${token}`,
inviteToken: token,
},
});
return {
emailIds: [job.id],
invite: {
email,
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
token,
kitaName: kita.name,
},
};
}
const reasonSchema = z
.string()
.trim()
@@ -215,6 +327,114 @@ export async function executeStaffCommand(
throw new Error(
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
);
if (
["educatorCreate", "educatorUpdate", "educatorInvite"].includes(
command,
)
) {
requireAdmin(actor);
if (command === "educatorInvite") {
const educator = await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: {
staffProfile: { include: { user: true, educator: true } },
},
});
if (!educator?.staffProfile?.user)
throw new Error(
"Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.",
);
return inviteStaff(
tx,
actor,
educator.staffProfile,
educator.staffProfile.user.email,
);
}
const input = educatorInput.parse(data);
const existing =
command === "educatorUpdate"
? await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: { staffProfile: { include: { user: true } } },
})
: null;
if (command === "educatorUpdate" && !existing)
throw new Error("Fachkraft nicht gefunden.");
if (
existing?.staffProfile?.user &&
existing.staffProfile.user.email !== input.email
)
throw new Error(
"Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.",
);
if (existing?.staffProfile)
assertStaffAccess(actor, existing.staffProfile, true);
// Dates are only supplied when creating the profile. Existing balances and dates stay intact.
const dates = !existing?.staffProfile
? onboardingDates.parse(data)
: null;
const values = {
firstName: input.firstName,
lastName: input.lastName,
active: input.active,
};
const educator = existing
? await tx.educator.update({
where: { id: existing.id },
data: values,
})
: await tx.educator.create({
data: { ...values, kitaId: actor.kitaId },
});
const profile =
existing?.staffProfile ??
(await tx.staffProfile.create({
data: {
kitaId: actor.kitaId,
educatorId: educator.id,
employmentStart: dateOnly(dates!.employmentStart),
trackingStart: dateOnly(dates!.trackingStart),
},
include: { user: true },
}));
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: existing ? "Stammdaten geändert" : "Fachkraft angelegt",
details: `${educator.firstName} ${educator.lastName}${dates ? ` · Beschäftigung ${dates.employmentStart}, Erfassung ${dates.trackingStart}, Anfangssaldo 0 Min.` : ""}`,
},
});
if (
profile.user &&
(profile.user.kitaId !== actor.kitaId ||
profile.user.role !== UserRole.ERZIEHER)
)
throw new Error(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
);
if (profile.userId) {
await tx.user.update({
where: { id: profile.userId },
data: { firstName: input.firstName, lastName: input.lastName },
});
return { educatorId: educator.id };
}
const invitation = await inviteStaff(
tx,
actor,
{ ...profile, educator },
input.email,
);
return { ...invitation, educatorId: educator.id };
}
if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER)
throw new Error(
@@ -445,76 +665,7 @@ export async function executeStaffCommand(
.email()
.toLowerCase()
.parse(data.email);
if (profile.userId) {
if (profile.user?.email !== email || profile.user.emailVerifiedAt)
throw new Error(
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
);
}
const existingUser = await tx.user.findUnique({ where: { email } });
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profileId } },
})))
)
throw new Error(
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
);
const user =
existingUser ??
(await tx.user.create({
data: {
email,
firstName: profile.educator.firstName,
lastName: profile.educator.lastName,
passwordHash: "",
role: UserRole.ERZIEHER,
kitaId: actor.kitaId,
},
}));
await tx.staffProfile.update({
where: { id: profileId },
data: { userId: user.id },
});
await history("Account verknüpft", email);
if (user.emailVerifiedAt) return {};
const token = randomUUID();
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.verificationToken.create({
data: {
identifier: user.id,
token,
expires: new Date(Date.now() + 7 * 86400000),
},
});
const kita = await tx.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true },
});
const job = await tx.staffEmail.create({
data: {
profileId,
userId: user.id,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
message: "Account aktivieren",
path: `/invite/${token}`,
inviteToken: token,
},
});
return {
emailIds: [job.id],
invite: {
email,
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
token,
kitaName: kita.name,
},
};
return inviteStaff(tx, actor, profile, email);
}
if (command === "pattern") {
requireAdmin(actor);
+269
View File
@@ -709,3 +709,272 @@ test("Entzogene Rollen, fehlender Consent und gesperrte Accounts können keine P
});
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: 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: 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,
);
});