feat: create and invite educators in a single form
This commit is contained in:
+24
-10
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user