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
@@ -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>
<form action={handleUpdate}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<form
onSubmit={(event) => {
event.preventDefault();
handleUpdate(new FormData(event.currentTarget));
}}
>
<DialogHeader>
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
</DialogHeader>
{editingEducator && (
<div className="grid gap-4 py-4">
<fieldset disabled={isPending} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-firstName">Vorname</Label>
<Input
@@ -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>
)}