Add educator leave management and working time accounts

This commit is contained in:
t.indorf
2026-10-07 15:25:55 +02:00
parent ec3f4f933d
commit 043fed2e46
19 changed files with 5631 additions and 200 deletions
@@ -1,6 +1,7 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -40,7 +41,9 @@ type EducatorDto = {
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(null);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(
null,
);
const [educatorToDelete, setEducatorToDelete] = useState<EducatorDto | null>(
null,
);
@@ -132,8 +135,16 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpenCreate(false)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Speichern</Button>
<Button
type="button"
variant="ghost"
onClick={() => setOpenCreate(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Speichern
</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -153,67 +164,88 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</Badge>
</CardHeader>
<CardContent className="px-0 pb-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={4} 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]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Keine Einträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege die erste Person im Team an.
</p>
</div>
</TableCell>
<TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
) : (
educators.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="pl-6 font-medium">{ed.firstName}</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<Button variant="ghost" size="icon" onClick={() => setEditingEducator(ed)}>
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setEducatorToDelete(ed)}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
<TableRow>
<TableCell colSpan={4} 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]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Keine Einträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Lege die erste Person im Team an.
</p>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
educators.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="pl-6 font-medium">
{ed.firstName}
</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="pr-6 text-right">
<Button
variant="outline"
size="sm"
asChild
className="mr-2"
>
<Link
href={`/dashboard/personal?tab=verwaltung&educator=${ed.id}`}
>
Personalprofil
</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingEducator(ed)}
>
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
onClick={() => setEducatorToDelete(ed)}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} löschen`}
>
<Trash2 className="h-4 w-4 [stroke-width:1.7]" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingEducator} onOpenChange={(open) => !open && setEditingEducator(null)}>
<Dialog
open={!!editingEducator}
onOpenChange={(open) => !open && setEditingEducator(null)}
>
<DialogContent>
<form action={handleUpdate}>
<DialogHeader>
@@ -223,21 +255,43 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-firstName">Vorname</Label>
<Input id="edit-firstName" name="firstName" defaultValue={editingEducator.firstName} required />
<Input
id="edit-firstName"
name="firstName"
defaultValue={editingEducator.firstName}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-lastName">Nachname</Label>
<Input id="edit-lastName" name="lastName" defaultValue={editingEducator.lastName} required />
<Input
id="edit-lastName"
name="lastName"
defaultValue={editingEducator.lastName}
required
/>
</div>
<div className="flex items-center gap-2 mt-2">
<Switch id="edit-active" name="active" defaultChecked={editingEducator.active} />
<Switch
id="edit-active"
name="active"
defaultChecked={editingEducator.active}
/>
<Label htmlFor="edit-active">Aktiv</Label>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setEditingEducator(null)}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>Aktualisieren</Button>
<Button
type="button"
variant="ghost"
onClick={() => setEditingEducator(null)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Aktualisieren
</Button>
</DialogFooter>
</form>
</DialogContent>
+16 -8
View File
@@ -19,12 +19,12 @@ export async function createEducator(rawPayload: unknown) {
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: {
@@ -34,7 +34,7 @@ export async function createEducator(rawPayload: unknown) {
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
@@ -49,12 +49,12 @@ export async function updateEducator(id: string, rawPayload: unknown) {
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.update({
where: {
@@ -67,7 +67,7 @@ export async function updateEducator(id: string, rawPayload: unknown) {
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
@@ -82,15 +82,23 @@ export async function deleteEducator(id: string) {
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
try {
const profile = await prisma.staffProfile.findFirst({
where: { educatorId: id, kitaId },
});
if (profile)
return {
error:
"Diese Fachkraft hat ein Personalprofil. Bitte auf inaktiv setzen, damit Zeiten und Konten erhalten bleiben.",
};
await prisma.educator.delete({
where: {
id,
kitaId,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
+2 -2
View File
@@ -36,8 +36,8 @@ export default async function ErzieherPage() {
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Stammdaten des Kita-Personals für interne Planung und spätere
Notdienst-Alarmierung.
Stammdaten des Teams und Zugang zu Beschäftigungsprofilen,
Urlaubsansprüchen und Arbeitszeitmodellen.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
+34 -10
View File
@@ -191,8 +191,7 @@ export default async function DashboardPage() {
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
dein Kind schnell für Abwesenheiten melden.
siehst du auf einen Blick, was bei {kita.name} ansteht.
</p>
</div>
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
@@ -203,6 +202,14 @@ export default async function DashboardPage() {
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{session.user.role === UserRole.ERZIEHER && (
<QuickCard
href="/dashboard/personal"
icon={Clock3}
title="Meine Arbeitszeit & Urlaub"
sub="Stunden erfassen, Urlaub beantragen und dein Zeitkonto ansehen."
/>
)}
{canReportAbsences ? (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
@@ -237,7 +244,11 @@ export default async function DashboardPage() {
</div>
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
<Stat label="Familien" value={familyCount} hint="registrierte Familien" />
<Stat
label="Familien"
value={familyCount}
hint="registrierte Familien"
/>
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
<Stat
label={`Notdienst ${targetData.monthName}`}
@@ -260,12 +271,22 @@ export default async function DashboardPage() {
Häufige Aktionen für Vorstand und Koordination.
</p>
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
<QuickCard
href="/dashboard/families"
icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
{session.user.role === UserRole.ADMIN && (
<QuickCard
href="/dashboard/personal"
icon={Clock3}
title="Personal & Freigaben"
sub="Urlaub, Arbeitswochen und Beschäftigungsprofile verwalten."
/>
)}
{session.user.role === UserRole.ADMIN && (
<QuickCard
href="/dashboard/families"
icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
)}
{kita.terminModuleEnabled ? (
<QuickCard
href="/dashboard/kalender"
@@ -274,7 +295,10 @@ export default async function DashboardPage() {
sub="Schließtage, Elternabende und Feste planen."
/>
) : null}
{kita.notdienstModuleEnabled ? (
{kita.notdienstModuleEnabled &&
[UserRole.ADMIN, UserRole.KOORDINATOR].includes(
session.user.role as "ADMIN" | "KOORDINATOR",
) ? (
<QuickCard
href="/dashboard/notdienst/plan"
icon={ListTodo}
+45
View File
@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireStaffActor } from "@/lib/staff/auth";
import { executeStaffCommand } from "@/lib/staff/service";
export type StaffActionResult = {
error?: string;
success?: boolean;
invitePath?: 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 });
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,
...(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.",
};
if (
error instanceof Error &&
!error.message.includes("prisma") &&
!error.message.includes("Invalid `")
)
return { error: error.message };
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
}
}
+757
View File
@@ -0,0 +1,757 @@
"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 "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;
}
toast.success("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 {
toast.success("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 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="Aktivierungslink erstellen"
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 Link 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">
Bitte persönlich an die Fachkraft weitergeben. Es wird keine
E-Mail versendet.
</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
+130 -98
View File
@@ -1,5 +1,7 @@
import { format } from "date-fns";
import { de } from "date-fns/locale";
import Link from "next/link";
import { UserRole } from "@prisma/client";
import {
AtSign,
Baby,
@@ -96,7 +98,9 @@ export default async function ProfilPage() {
</em>
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen.
{user.role === UserRole.ERZIEHER
? "Deine persönlichen Daten, dein Arbeitsbereich und Datenschutzeinstellungen."
: "Deine persönlichen Daten, verknüpfte Kinder und DSGVO-Einstellungen."}
</p>
</header>
@@ -124,25 +128,31 @@ export default async function ProfilPage() {
label="Telefon"
value={user.phone ? user.phone : <EmptyDefValue />}
/>
<DefItem icon={Shield} label="Rolle" value={<RoleBadge role={user.role} />} />
<DefItem
icon={Home}
label="Haushalt"
value={
user.family ? (
user.family.name
) : (
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
<CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`}
/>
</div>
)
}
icon={Shield}
label="Rolle"
value={<RoleBadge role={user.role} />}
/>
{user.role !== UserRole.ERZIEHER && (
<DefItem
icon={Home}
label="Haushalt"
value={
user.family ? (
user.family.name
) : (
<div className="grid gap-3 rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-3">
<p className="text-sm font-normal leading-5 text-[var(--ink-soft)]">
Dein Account ist noch keinem Haushalt zugeordnet.
</p>
<CreateFamilyDialog
defaultFamilyName={`Familie ${user.lastName}`}
/>
</div>
)
}
/>
)}
<DefItem
icon={CalendarHeart}
label="Mitglied seit"
@@ -153,6 +163,24 @@ export default async function ProfilPage() {
</Card>
<div className="grid gap-4">
{user.role === UserRole.ERZIEHER && (
<Card>
<CardHeader>
<CardTitle>Meine Arbeitszeit & Urlaub</CardTitle>
<CardDescription>
Wochenmodell, Urlaubsanspruch, Zeiterfassung und Überstunden.
</CardDescription>
</CardHeader>
<CardContent>
<Link
href="/dashboard/personal"
className="font-medium text-[var(--accent-deep)] underline underline-offset-4"
>
Personalbereich öffnen →
</Link>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
@@ -180,87 +208,91 @@ export default async function ProfilPage() {
</CardContent>
</Card>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Baby className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<MyChildrenManager
items={(user.family?.children ?? []).map((child) => ({
id: child.id,
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth
? child.dateOfBirth.toISOString().slice(0, 10)
: null,
}))}
canManage={!!user.familyId}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{user.dutyAssignments.length === 0 ? (
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine festen Ämter übernommen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Dienste und Rollen erscheinen hier, sobald sie zugewiesen
sind.
</p>
</div>
) : (
<div className="grid gap-2">
{user.dutyAssignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="text-sm font-semibold text-[var(--ink)]">
{assignment.duty.name}
</p>
<p className="text-xs text-[var(--ink-soft)]">
laufender Dienst
</p>
</div>
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
Aktiv
</span>
{user.role !== UserRole.ERZIEHER && (
<>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Baby className="h-4 w-4 [stroke-width:1.7]" />
</div>
))}
</div>
)}
</CardContent>
</Card>
<div>
<CardTitle>Meine Kinder</CardTitle>
<CardDescription className="mt-2">
Kinder deines Haushalts verwalten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<MyChildrenManager
items={(user.family?.children ?? []).map((child) => ({
id: child.id,
firstName: child.firstName,
lastName: child.lastName,
dateOfBirth: child.dateOfBirth
? child.dateOfBirth.toISOString().slice(0, 10)
: null,
}))}
canManage={!!user.familyId}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<div>
<CardTitle>Meine Ämter & Dienste</CardTitle>
<CardDescription className="mt-2">
Feste Aufgaben und laufende Zuständigkeiten.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{user.dutyAssignments.length === 0 ? (
<div className="flex min-h-32 flex-col items-center justify-center rounded-[14px] border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-5 text-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Shield className="h-4 w-4 [stroke-width:1.7]" />
</div>
<p className="mt-3 text-sm font-medium text-[var(--ink)]">
Keine festen Ämter übernommen
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
Dienste und Rollen erscheinen hier, sobald sie
zugewiesen sind.
</p>
</div>
) : (
<div className="grid gap-2">
{user.dutyAssignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--surface-2)] p-[10px_12px]"
>
<div>
<p className="text-sm font-semibold text-[var(--ink)]">
{assignment.duty.name}
</p>
<p className="text-xs text-[var(--ink-soft)]">
laufender Dienst
</p>
</div>
<span className="rounded-full border border-[var(--accent-mid)] bg-[var(--accent-soft)] px-2.5 py-1 text-[10.5px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.1em]">
Aktiv
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
</div>