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