Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+71
View File
@@ -0,0 +1,71 @@
"use server";
import { publicActionError } from "@/lib/action-error";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireStaffActor } from "@/lib/staff/auth";
import { executeStaffCommand } from "@/lib/staff/service";
import { deliverStaffEmails } from "@/lib/staff/email";
export type StaffActionResult = {
educatorId?: string;
error?: string;
success?: boolean;
invitePath?: string;
warning?: string;
message?: string;
};
export async function staffAction(
command: string,
data: Record<string, unknown>,
): Promise<StaffActionResult> {
const actor = await requireStaffActor();
try {
const result = await executeStaffCommand(actor, { command, data });
let warning: string | undefined;
let message: string | undefined;
if (result.emailIds?.length) {
try {
const delivery = await deliverStaffEmails(result.emailIds);
if (delivery.failed)
warning =
"Gespeichert. Einige E-Mails konnten nicht versendet werden. Im Personalbereich kannst du den Versand erneut versuchen.";
else if (
process.env.NODE_ENV === "development" &&
!process.env.RESEND_API_KEY
)
warning = "Gespeichert. Der E-Mail-Versand wurde lokal simuliert.";
else if (result.invite && delivery.sent)
message = "Aktivierungslink per E-Mail versendet.";
else if (command === "emailRetry")
message = "Ausstehende E-Mails wurden verarbeitet.";
} catch {
warning =
"Gespeichert. E-Mail-Versand ist noch ausstehend; bitte im Personalbereich erneut versuchen.";
}
}
revalidatePath("/dashboard/personal");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/erzieher");
revalidatePath("/dashboard");
// Returned only to the authenticated admin who generated this single-use link.
return {
success: true,
educatorId: result.educatorId,
warning,
message,
...(result.invite
? { invitePath: `/invite/${result.invite.token}` }
: {}),
};
} catch (error) {
if (error instanceof z.ZodError)
return {
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
};
const message = publicActionError(error, "");
if (message) return { error: message };
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
}
}
+784
View File
@@ -0,0 +1,784 @@
"use client";
import { useId, useState, useTransition, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DAY_NAMES,
clockMinute,
minuteClock,
todayKey,
} from "@/lib/staff/calculations";
import { staffAction, type StaffActionResult } from "./actions";
const control =
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
export function Field({
label,
name,
type = "text",
value,
required = true,
min,
max,
step,
}: {
label: string;
name: string;
type?: string;
value?: string | number;
required?: boolean;
min?: string | number;
max?: string | number;
step?: string | number;
}) {
const id = useId();
return (
<div className="grid gap-1.5">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<Input
id={id}
name={name}
type={type}
defaultValue={value}
required={required}
min={min}
max={max}
step={step}
/>
</div>
);
}
function transform(command: string, form: FormData) {
const text = (key: string) => String(form.get(key) ?? "");
const num = (key: string) => Number(text(key));
const hours = (key: string) => Math.round(num(key) * 60);
switch (command) {
case "emailPreference":
return { enabled: form.get("enabled") === "on" };
case "setup":
return {
educatorId: text("educatorId"),
employmentStart: text("employmentStart"),
employmentEnd: text("employmentEnd") || null,
trackingStart: text("trackingStart"),
openingMinutes: hours("openingHours"),
reason: text("reason"),
};
case "invite":
return { email: text("email") };
case "employmentEnd":
return {
employmentEnd: text("employmentEnd") || null,
reason: text("reason"),
};
case "pattern":
return {
effectiveFrom: text("effectiveFrom"),
dailyMinutes: DAY_NAMES.map((_, i) => hours(`day${i}`)),
reason: text("reason"),
};
case "account":
return {
year: num("year"),
entitlementUnits: Math.round(num("entitlement") * 2),
carryUnits: Math.round(num("carry") * 2),
reason: text("reason"),
};
case "override":
return {
date: text("date"),
minutes: hours("hours"),
reason: text("reason"),
};
case "adjust":
return {
date: text("date"),
minutes: hours("hours"),
reason: text("reason"),
};
case "holiday":
return { date: text("date"), name: text("name") };
case "time":
return {
date: text("date"),
startMinute: clockMinute(text("start")),
endMinute: clockMinute(text("end")),
breakMinutes: num("breakMinutes"),
note: text("note"),
};
case "leave":
return {
kind: text("kind"),
startDate: text("startDate"),
endDate: text("endDate"),
halfDay: form.get("halfDay") === "on",
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
note: text("note"),
};
case "leaveReview":
case "weekReview":
return { reason: text("reason") };
default:
return {};
}
}
function StaffForm({
command,
profileId,
extra = {},
children,
label = "Speichern",
onSuccess,
}: {
command: string;
profileId?: string;
extra?: Record<string, unknown>;
children: ReactNode;
label?: string;
onSuccess?: (result: StaffActionResult) => void;
}) {
const [pending, start] = useTransition();
const [error, setError] = useState("");
const router = useRouter();
return (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
setError("");
start(async () => {
try {
const result = await staffAction(command, {
...transform(command, form),
...extra,
...(profileId ? { profileId } : {}),
});
if (result.error) {
setError(result.error);
return;
}
if (result.warning) toast.warning(result.warning);
else toast.success(result.message ?? "Gespeichert.");
onSuccess?.(result);
router.refresh();
} catch (error) {
setError(
error instanceof Error
? error.message
: "Speichern fehlgeschlagen.",
);
}
});
}}
>
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
{children}
<div>
<Button type="submit">
{pending ? "Wird gespeichert …" : label}
</Button>
</div>
</fieldset>
{error && (
<p role="alert" className="text-sm text-[var(--danger)]">
{error}
</p>
)}
</form>
);
}
export function CommandButton({
command,
profileId,
data,
children,
variant = "outline",
}: {
command: string;
profileId: string;
data: Record<string, unknown>;
children: ReactNode;
variant?: "outline" | "ghost" | "default";
}) {
const [pending, start] = useTransition();
const router = useRouter();
return (
<Button
type="button"
variant={variant}
size="sm"
disabled={pending}
onClick={() =>
start(async () => {
const result = await staffAction(command, { ...data, profileId });
if (result.error) toast.error(result.error);
else {
if (result.warning) toast.warning(result.warning);
else toast.success(result.message ?? "Gespeichert.");
router.refresh();
}
})
}
>
{pending ? "Bitte warten …" : children}
</Button>
);
}
export function ReviewButton({
command,
profileId,
data,
label,
approve,
}: {
command: "leaveReview" | "weekReview";
profileId: string;
data: Record<string, unknown>;
label: string;
approve: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant={approve ? "default" : "outline"}>
{label}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{label}</DialogTitle>
<DialogDescription>
Die Entscheidung wird im Personalverlauf dokumentiert.
</DialogDescription>
</DialogHeader>
<StaffForm
command={command}
profileId={profileId}
extra={{ ...data, approve }}
label={label}
onSuccess={() => setOpen(false)}
>
<Field label="Begründung / Rückmeldung" name="reason" />
</StaffForm>
</DialogContent>
</Dialog>
);
}
export function SetupForm({
educatorId,
values,
}: {
educatorId: string;
values?: {
employmentStart: string;
employmentEnd: string | null;
trackingStart: string;
openingMinutes: number;
};
}) {
return (
<StaffForm
command="setup"
extra={{ educatorId }}
label="Beschäftigungsprofil speichern"
>
<input type="hidden" name="educatorId" value={educatorId} />
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="Beschäftigung ab"
name="employmentStart"
type="date"
value={values?.employmentStart ?? todayKey()}
/>
<Field
label="Beschäftigung bis (optional)"
name="employmentEnd"
type="date"
value={values?.employmentEnd ?? ""}
required={false}
/>
<Field
label="Zeiterfassung ab"
name="trackingStart"
type="date"
value={values?.trackingStart ?? todayKey()}
/>
<Field
label="Anfangssaldo in Stunden"
name="openingHours"
type="number"
step="any"
value={(values?.openingMinutes ?? 0) / 60}
/>
</div>
<Field label="Begründung" name="reason" value="Ersteinrichtung" />
</StaffForm>
);
}
export function TimeForm({
profileId,
initial,
}: {
profileId: string;
initial?: {
id: string;
date: string;
startMinute: number;
endMinute: number;
breakMinutes: number;
note: string | null;
};
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant={initial ? "outline" : "default"}
size={initial ? "sm" : "default"}
>
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{initial ? "Arbeitszeit bearbeiten" : "Arbeitszeit erfassen"}
</DialogTitle>
<DialogDescription>
Erfasse Beginn, Ende und die gesamte Pause dieses Arbeitsblocks.
Weitere Blöcke am selben Tag sind möglich.
</DialogDescription>
</DialogHeader>
<StaffForm
command="time"
profileId={profileId}
extra={initial ? { id: initial.id } : {}}
label="Arbeitszeit speichern"
onSuccess={() => setOpen(false)}
>
<Field
label="Datum"
name="date"
type="date"
value={initial?.date ?? todayKey()}
max={todayKey()}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Beginn"
name="start"
type="time"
value={minuteClock(initial?.startMinute ?? 480)}
/>
<Field
label="Ende"
name="end"
type="time"
value={minuteClock(initial?.endMinute ?? 960)}
/>
</div>
<Field
label="Pause in Minuten"
name="breakMinutes"
type="number"
min={0}
step={1}
value={initial?.breakMinutes ?? 30}
/>
<Field
label="Notiz (optional)"
name="note"
value={initial?.note ?? ""}
required={false}
/>
</StaffForm>
</DialogContent>
</Dialog>
);
}
export function LeaveForm({
profileId,
admin,
initial,
}: {
profileId: string;
admin: boolean;
initial?: {
id: string;
kind: string;
startDate: string;
endDate: string;
halfDay: boolean;
minutes: number | null;
note: string | null;
};
}) {
const [open, setOpen] = useState(false);
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
const id = useId();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size={initial ? "sm" : "default"} variant="outline">
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{initial ? "Antrag ändern" : "Abwesenheit beantragen"}
</DialogTitle>
<DialogDescription>
Urlaub verbraucht nur deine vorgesehenen Arbeitstage.
Freizeitausgleich wird in Stunden für einen einzelnen Tag beantragt.
</DialogDescription>
</DialogHeader>
<StaffForm
command="leave"
profileId={profileId}
extra={initial ? { id: initial.id } : {}}
label={
kind === "SICKNESS" ? "Krankheit hinterlegen" : "Antrag speichern"
}
onSuccess={() => setOpen(false)}
>
<div className="grid gap-1.5">
<label htmlFor={`${id}-kind`} className="text-sm font-medium">
Art
</label>
<select
id={`${id}-kind`}
name="kind"
className={control}
value={kind}
onChange={(e) => setKind(e.target.value)}
>
<option value="VACATION">Urlaub</option>
<option value="TIME_OFF">Freizeitausgleich</option>
{admin && <option value="SICKNESS">Krankheit</option>}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label="Von"
name="startDate"
type="date"
value={initial?.startDate ?? todayKey()}
/>
<Field
label="Bis"
name="endDate"
type="date"
value={initial?.endDate ?? todayKey()}
/>
</div>
{kind === "VACATION" && (
<label className="flex items-center gap-2 text-sm">
<input
name="halfDay"
type="checkbox"
defaultChecked={initial?.halfDay}
/>
Halber Urlaubstag (Von = Bis)
</label>
)}
{kind === "TIME_OFF" && (
<Field
label="Freizeitausgleich in Stunden (Von = Bis)"
name="hours"
type="number"
min={1 / 60}
max={16}
step="any"
value={(initial?.minutes ?? 60) / 60}
/>
)}
<Field
label="Notiz (optional)"
name="note"
value={initial?.note ?? ""}
required={false}
/>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Der Antrag wird anhand deines Wochenmodells, der Tagesausnahmen und
der Feiertage geprüft. Ein halber Tag entspricht der Hälfte der
Sollzeit.
</p>
</StaffForm>
</DialogContent>
</Dialog>
);
}
function SettingsSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<details className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5">
<summary className="cursor-pointer font-semibold">{title}</summary>
<div className="mt-5">{children}</div>
</details>
);
}
export function StaffEmailSettings({ enabled }: { enabled: boolean }) {
return (
<StaffForm command="emailPreference" label="E-Mail-Einstellung speichern">
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
name="enabled"
defaultChecked={enabled}
className="mt-1 accent-[var(--accent-deep)]"
/>
<span>
Entscheidungen zu Urlaub, Freizeitausgleich und Arbeitswochen per
E-Mail erhalten.
</span>
</label>
<p className="text-sm text-[var(--ink-soft)]">
Aktivierungslinks und Hinweise innerhalb der App bleiben davon
unabhängig.
</p>
</StaffForm>
);
}
export function SettingsPanel({
profileId,
educatorId,
employment,
email,
verified,
pattern,
year,
account,
}: {
profileId: string;
educatorId: string;
employment: {
employmentStart: string;
employmentEnd: string | null;
trackingStart: string;
openingMinutes: number;
};
email?: string;
verified: boolean;
pattern?: { effectiveFrom: string; dailyMinutes: number[] };
year: number;
account?: { entitlementUnits: number; carryUnits: number };
}) {
const [invitePath, setInvitePath] = useState("");
return (
<div className="grid gap-3">
<SettingsSection title="Zugang zur Zeiterfassung">
{verified ? (
<p className="text-sm">Aktives Konto: {email}</p>
) : (
<StaffForm
command="invite"
profileId={profileId}
label="Per E-Mail einladen / Account verknüpfen"
onSuccess={(r) => setInvitePath(r.invitePath ?? "")}
>
<Field
label="E-Mail-Adresse"
name="email"
type="email"
value={email ?? ""}
/>
<p className="text-sm text-[var(--ink-soft)]">
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
persönliche Aktivierungslink wird per E-Mail versendet und ist
sieben Tage gültig. Ein neuer Link ersetzt den vorherigen.
</p>
</StaffForm>
)}
{invitePath && (
<div className="mt-4 grid gap-2 rounded-lg bg-[var(--accent-soft)] p-4">
<label htmlFor="activation-link" className="text-sm font-medium">
Persönlicher Aktivierungslink
</label>
<Input
id="activation-link"
readOnly
value={`${window.location.origin}${invitePath}`}
/>
<Button
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(
`${window.location.origin}${invitePath}`,
);
toast.success("Link kopiert.");
}}
>
Link kopieren
</Button>
<p className="text-xs">
Der Link wird per E-Mail versendet. Bei Bedarf kannst du ihn
zusätzlich persönlich an die Fachkraft weitergeben.
</p>
</div>
)}
</SettingsSection>
<SettingsSection title="Feste Wochentage & Sollstunden">
<StaffForm
command="pattern"
profileId={profileId}
label="Wochenmodell speichern"
>
<Field
label="Gültig ab"
name="effectiveFrom"
type="date"
value={pattern?.effectiveFrom ?? employment.trackingStart}
/>
<div className="grid gap-3 sm:grid-cols-2">
{DAY_NAMES.map((day, i) => (
<Field
key={day}
label={`${day} · Stunden`}
name={`day${i}`}
type="number"
min={0}
max={16}
step="any"
value={(pattern?.dailyMinutes[i] ?? 0) / 60}
/>
))}
</div>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
0 Stunden = frei. Stunden sind Nettozeiten ohne Pause. Änderungen
gelten ab dem gewählten Datum; ältere Modelle bleiben erhalten.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title={`Urlaubsanspruch & Übertrag · ${year}`}>
<StaffForm command="account" profileId={profileId}>
<Field
label="Jahr"
name="year"
type="number"
min={2000}
max={2100}
value={year}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Jahresanspruch in Tagen"
name="entitlement"
type="number"
min={0}
max={400}
step={0.5}
value={(account?.entitlementUnits ?? 0) / 2}
/>
<Field
label="Übertrag in Tagen"
name="carry"
type="number"
min={0}
max={400}
step={0.5}
value={(account?.carryUnits ?? 0) / 2}
/>
</div>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
Anspruch individuell für die Arbeitstage dieser Person hinterlegen.
Der Übertrag wird ausdrücklich festgelegt.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title="Ausnahme für einen einzelnen Tag">
<StaffForm command="override" profileId={profileId}>
<Field label="Datum" name="date" type="date" value={todayKey()} />
<Field
label="Abweichende Sollstunden (0 = frei)"
name="hours"
type="number"
min={0}
max={16}
step="any"
value={0}
/>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
Bei einem Tausch beide Tage erfassen: den ursprünglichen Tag auf 0
setzen und den neuen Arbeitstag mit Sollstunden hinterlegen.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title="Zeitkonto korrigieren">
<StaffForm command="adjust" profileId={profileId}>
<Field
label="Buchungsdatum"
name="date"
type="date"
value={todayKey()}
/>
<Field
label="Korrektur in Stunden (mit Vorzeichen)"
name="hours"
type="number"
step="any"
/>
<Field label="Begründung" name="reason" />
<p className="text-xs text-[var(--ink-soft)]">
Korrekturen bleiben im Verlauf erhalten. Falsche Buchungen durch
eine Gegenbuchung berichtigen.
</p>
</StaffForm>
</SettingsSection>
<SettingsSection title="Beschäftigungsbeginn & Anfangssaldo">
<SetupForm educatorId={educatorId} values={employment} />
<p className="mt-3 text-xs text-[var(--ink-soft)]">
Die Grunddaten sind geschützt, sobald Zeiten, Wochen oder Anträge
erfasst wurden.
</p>
</SettingsSection>
<SettingsSection title="Beschäftigungsende ändern">
<StaffForm command="employmentEnd" profileId={profileId}>
<Field
label="Beschäftigung bis (leer = unbefristet)"
name="employmentEnd"
type="date"
value={employment.employmentEnd ?? ""}
required={false}
/>
<Field label="Begründung" name="reason" />
</StaffForm>
</SettingsSection>
</div>
);
}
export function HolidayForm() {
return (
<StaffForm command="holiday" label="Feiertag speichern">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Datum" name="date" type="date" />
<Field label="Bezeichnung" name="name" />
</div>
</StaffForm>
);
}
File diff suppressed because it is too large Load Diff