Files
kita-planer/src/app/dashboard/personal/page.tsx
T

1265 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { UserRole } from "@prisma/client";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Stat } from "@/components/ui/stat";
import { prisma } from "@/lib/prisma";
import { requireStaffActor } from "@/lib/staff/auth";
import { getStaffProfile } from "@/lib/staff/service";
import {
DAY_NAMES,
KIND_LABELS,
STATUS_LABELS,
addDays,
calculateWeek,
dateKey,
dateOnly,
dateSchema,
datesBetween,
formatMinutes,
getTimeBalance,
minuteClock,
readSnapshot,
shortDate,
todayKey,
vacationBalance,
weekStart,
} from "@/lib/staff/calculations";
import {
CommandButton,
HolidayForm,
LeaveForm,
ReviewButton,
SettingsPanel,
SetupForm,
TimeForm,
} from "./forms";
export const metadata = { title: "Personal & Arbeitszeit · Kita-Planer" };
const inputClass =
"rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm";
function Panel({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: ReactNode;
}) {
return (
<Card>
<CardHeader>
<CardTitle as="h2">{title}</CardTitle>
{hint && <p className="text-sm text-[var(--ink-soft)]">{hint}</p>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function Empty({ children }: { children: ReactNode }) {
return (
<p className="rounded-xl border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-sm text-[var(--ink-soft)]">
{children}
</p>
);
}
export default async function PersonalPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const actor = await requireStaffActor();
const params = await searchParams;
const admin = actor.role === UserRole.ADMIN;
const today = todayKey();
const pick = (key: string) =>
typeof params[key] === "string" ? (params[key] as string) : "";
const yearInput = Number(pick("year"));
const year =
Number.isInteger(yearInput) && yearInput >= 2000 && yearInput <= 2100
? yearInput
: Number(today.slice(0, 4));
const requestedMonth = pick("month");
const month =
/^\d{4}-(0[1-9]|1[0-2])$/.test(requestedMonth) &&
dateSchema.safeParse(`${requestedMonth}-01`).success
? requestedMonth
: today.slice(0, 7);
const monthStart = `${month}-01`;
const monthEndDate = dateOnly(monthStart);
monthEndDate.setUTCMonth(monthEndDate.getUTCMonth() + 1);
monthEndDate.setUTCDate(0);
const monthEnd = dateKey(monthEndDate);
const start = weekStart(
dateSchema.safeParse(pick("week")).success ? pick("week") : today,
);
const end = addDays(start, 6);
const tabs = [
...(admin ? [{ key: "team", label: "Team & Freigaben" }] : []),
{ key: "zeit", label: "Arbeitszeit" },
{ key: "urlaub", label: "Urlaub & Zeitkonto" },
{ key: "kalender", label: "Teamkalender" },
{
key: "verwaltung",
label: admin ? "Personalprofil" : "Mein Arbeitsmodell",
},
];
const tab = tabs.some((t) => t.key === pick("tab"))
? pick("tab")
: admin
? "team"
: "zeit";
const [
educators,
holidays,
notifications,
calendarLeaves,
pendingLeaves,
pendingWeeks,
pendingEmails,
] = await Promise.all([
// All queries stay scoped to the current Kita and recipient permissions.
admin
? prisma.educator.findMany({
where: { kitaId: actor.kitaId },
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
include: {
staffProfile: {
select: {
id: true,
userId: true,
workPatterns: true,
leaveAccounts: true,
},
},
},
})
: Promise.resolve([]),
prisma.staffHoliday.findMany({
where: { kitaId: actor.kitaId },
orderBy: { date: "asc" },
}),
prisma.staffNotification.findMany({
where: {
userId: actor.id,
profile: { kitaId: actor.kitaId },
readAt: null,
},
orderBy: { createdAt: "desc" },
take: 20,
}),
tab === "kalender"
? prisma.staffLeave.findMany({
where: {
profile: { kitaId: actor.kitaId },
status: { in: ["APPROVED", "CANCEL_REQUESTED"] },
startDate: { lte: dateOnly(monthEnd) },
endDate: { gte: dateOnly(monthStart) },
},
select: {
id: true,
kind: true,
halfDay: true,
days: { select: { date: true, creditMinutes: true } },
profile: {
select: {
educator: { select: { firstName: true, lastName: true } },
},
},
},
})
: Promise.resolve([]),
admin
? prisma.staffLeave.findMany({
where: {
profile: { kitaId: actor.kitaId },
status: { in: ["PENDING", "CANCEL_REQUESTED"] },
},
include: {
profile: { select: { educator: true, userId: true } },
days: true,
},
orderBy: { createdAt: "asc" },
})
: Promise.resolve([]),
admin
? prisma.staffWeek.findMany({
where: { profile: { kitaId: actor.kitaId }, status: "SUBMITTED" },
include: { profile: { select: { educator: true, userId: true } } },
orderBy: { weekStart: "asc" },
})
: Promise.resolve([]),
prisma.staffEmail.count({
where: {
profile: { kitaId: actor.kitaId },
...(admin ? {} : { userId: actor.id }),
sentAt: null,
skippedAt: null,
},
}),
]);
const selectedEducator =
educators.find((e) => e.id === pick("educator")) ??
educators.find((e) => e.staffProfile?.userId === actor.id) ??
educators.find((e) => e.active) ??
educators[0];
const profile =
admin && !selectedEducator?.staffProfile
? null
: await getStaffProfile(
actor,
admin ? selectedEducator?.staffProfile?.id : undefined,
);
const url = (values: Record<string, string>) => {
const q = new URLSearchParams({
tab,
year: String(year),
month,
week: start,
...(selectedEducator ? { educator: selectedEducator.id } : {}),
...values,
});
return `/dashboard/personal?${q}`;
};
const holidaySet = new Set(holidays.map((h) => dateKey(h.date)));
const account = profile?.leaveAccounts.find((a) => a.year === year);
const vacation =
account && profile ? vacationBalance(account, profile.leaves) : null;
const time = profile
? getTimeBalance(
profile,
profile.weeks,
profile.adjustments,
profile.leaves,
)
: null;
const sheet = profile?.weeks.find((w) => dateKey(w.weekStart) === start);
const days = profile
? sheet?.status === "APPROVED"
? readSnapshot(sheet.dailySnapshot)
: calculateWeek(
profile,
holidaySet,
start,
profile.timeEntries,
profile.leaves,
)
: [];
const editable = !sheet || sheet.status === "DRAFT";
const weekEntries =
profile?.timeEntries.filter(
(e) => dateKey(e.date) >= start && dateKey(e.date) <= end,
) ?? [];
const totals = days.reduce(
(s, d) => ({
expected: s.expected + d.expected,
worked: s.worked + d.worked,
credit: s.credit + d.credit,
balance: s.balance + d.balance,
}),
{ expected: 0, worked: 0, credit: 0, balance: 0 },
);
const currentPattern = profile?.workPatterns
.filter((p) => dateKey(p.effectiveFrom) <= today)
.at(-1);
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex flex-wrap items-end justify-between gap-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
TEAM · PERSONALBEREICH
</p>
<h1 className="mt-2 text-4xl tracking-tight">
{admin ? "Personal &" : "Meine"}{" "}
<em className="font-normal text-[var(--accent-deep)]">
Arbeitszeit
</em>
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
Feste Arbeitstage, erfasste Stunden und Abwesenheiten an einem Ort.
</p>
</div>
{profile && (
<div className="flex flex-wrap gap-2">
<TimeForm profileId={profile.id} />
<LeaveForm profileId={profile.id} admin={admin} />
</div>
)}
</header>
{pendingEmails > 0 && (
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
<p className="text-sm">
{pendingEmails} E-Mail-Hinweise warten noch auf den Versand.
</p>
<CommandButton command="emailRetry" profileId="" data={{}}>
Versand erneut versuchen
</CommandButton>
</div>
)}
{notifications.length > 0 && (
<details className="mb-6 rounded-xl border border-[var(--accent-mid)] bg-[var(--accent-soft)] p-4">
<summary className="cursor-pointer text-sm font-semibold">
{notifications.length} neue Hinweise
</summary>
<div className="mt-3 grid gap-3">
{notifications.map((n) => (
<div
key={n.id}
className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline)] pt-3"
>
<p className="max-w-3xl text-sm">{n.message}</p>
<CommandButton
command="noticeRead"
profileId={n.profileId}
data={{ id: n.id }}
variant="ghost"
>
Gelesen
</CommandButton>
</div>
))}
</div>
</details>
)}
{admin && (
<form className="mb-6 flex flex-wrap items-end gap-3" method="get">
<input type="hidden" name="tab" value={tab} />
<input type="hidden" name="year" value={year} />
<label
className="grid gap-2 text-sm font-medium"
htmlFor="staff-selector"
>
Personalprofil
<select
key={selectedEducator?.id}
id="staff-selector"
name="educator"
defaultValue={selectedEducator?.id}
className={inputClass}
>
{educators.map((e) => (
<option key={e.id} value={e.id}>
{e.firstName} {e.lastName}
{!e.active ? " · inaktiv" : ""}
</option>
))}
</select>
</label>
<Button variant="outline" type="submit">
Öffnen
</Button>
<Button variant="ghost" asChild>
<Link href="/dashboard/erzieher">Personalstamm</Link>
</Button>
</form>
)}
<nav
aria-label="Personalbereich"
className="mb-7 flex gap-2 overflow-x-auto border-b border-[var(--hairline)] pb-3"
>
{tabs.map((t) => (
<Link
key={t.key}
href={url({ tab: t.key })}
aria-current={tab === t.key ? "page" : undefined}
className={`shrink-0 rounded-lg px-4 py-2 text-sm font-medium ${tab === t.key ? "bg-[var(--accent-soft)] text-[var(--accent-deep)]" : "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]"}`}
>
{t.label}
</Link>
))}
</nav>
{tab === "team" && admin ? (
<div className="grid gap-5">
<div className="grid gap-3 sm:grid-cols-3">
<Stat
label="Personalprofile"
value={educators.filter((e) => e.staffProfile).length}
hint={`${educators.length} Personen im Personalstamm`}
/>
<Stat label="Offene Abwesenheiten" value={pendingLeaves.length} />
<Stat
label="Arbeitswochen zur Prüfung"
value={pendingWeeks.length}
/>
</div>
<Panel
title="Teamübersicht"
hint="Für die Ersteinrichtung Beschäftigung, Wochenmodell, Urlaubskonto und Zugang hinterlegen."
>
<div className="grid gap-2">
{educators.length === 0 && (
<Empty>
Bitte zuerst eine Fachkraft im Personalstamm anlegen.
</Empty>
)}
{educators.map((e) => (
<Link
key={e.id}
href={url({ educator: e.id, tab: "verwaltung" })}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-[var(--surface-2)] p-4"
>
<span className="font-medium">
{e.firstName} {e.lastName}
</span>
<span className="text-sm text-[var(--ink-soft)]">
{!e.staffProfile
? "Profil einrichten →"
: `${e.staffProfile.workPatterns.length ? "Wochenmodell ✓" : "Wochenmodell fehlt"} · ${e.staffProfile.leaveAccounts.some((a) => a.year === year) ? `Urlaub ${year} ✓` : `Urlaub ${year} fehlt`} · ${e.staffProfile.userId ? "Account verknüpft" : "Zugang fehlt"} →`}
</span>
</Link>
))}
</div>
</Panel>
<Panel title="Abwesenheiten freigeben">
{pendingLeaves.length === 0 ? (
<Empty>Alle Abwesenheitsanträge sind bearbeitet.</Empty>
) : (
<div className="grid gap-4">
{pendingLeaves.map((l) => (
<div
key={l.id}
className="rounded-xl border border-[var(--hairline)] p-4"
>
<div className="flex flex-wrap justify-between gap-3">
<div>
<Link
href={url({
educator: l.profile.educator.id,
tab: "urlaub",
})}
className="font-medium underline underline-offset-4"
>
{l.profile.educator.firstName}{" "}
{l.profile.educator.lastName}
</Link>
<p className="mt-1 text-sm">
{KIND_LABELS[l.kind]} ·{" "}
{shortDate(dateKey(l.startDate))} –{" "}
{shortDate(dateKey(l.endDate))} ·{" "}
{l.status === "CANCEL_REQUESTED"
? "Stornierung"
: l.kind === "VACATION"
? `${l.days.reduce((s, d) => s + d.vacationUnits, 0) / 2} Tage`
: formatMinutes(l.minutes ?? 0)}
</p>
{l.note && (
<p className="mt-2 text-sm text-[var(--ink-soft)]">
{l.note}
</p>
)}
</div>
{l.profile.userId !== actor.id && (
<div className="flex flex-wrap items-center gap-2">
<ReviewButton
command="leaveReview"
profileId={l.profileId}
data={{ id: l.id }}
approve
label={
l.status === "CANCEL_REQUESTED"
? "Stornierung freigeben"
: "Genehmigen"
}
/>
<ReviewButton
command="leaveReview"
profileId={l.profileId}
data={{ id: l.id }}
approve={false}
label="Ablehnen"
/>
</div>
)}
</div>
</div>
))}
</div>
)}
</Panel>
<Panel title="Arbeitswochen freigeben">
{pendingWeeks.length === 0 ? (
<Empty>Keine eingereichten Wochen zur Prüfung.</Empty>
) : (
<div className="grid gap-3">
{pendingWeeks.map((w) => (
<div
key={w.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--hairline)] p-4"
>
<Link
className="text-sm underline underline-offset-4"
href={url({
educator: w.profile.educator.id,
tab: "zeit",
week: dateKey(w.weekStart),
})}
>
{w.profile.educator.firstName}{" "}
{w.profile.educator.lastName} · Woche ab{" "}
{shortDate(dateKey(w.weekStart))} prüfen →
</Link>
</div>
))}
</div>
)}
</Panel>
</div>
) : (
<>
{!profile && tab !== "kalender" && (
<Panel
title={
admin
? "Beschäftigungsprofil einrichten"
: "Dein Personalprofil fehlt noch"
}
>
{admin && selectedEducator ? (
<SetupForm educatorId={selectedEducator.id} />
) : (
<Empty>
{admin
? "Lege zuerst eine Fachkraft im Personalstamm an."
: "Bitte den Kita-Admin bitten, deinen Account mit deinem Personalprofil zu verknüpfen."}
</Empty>
)}
</Panel>
)}
{profile && tab !== "kalender" && (
<>
<p className="mb-4 text-sm font-medium">
{profile.educator.firstName} {profile.educator.lastName}{" "}
<span className="font-normal text-[var(--ink-soft)]">
· Erfassung seit {shortDate(dateKey(profile.trackingStart))}
</span>
</p>
<div className="mb-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat
label="Bestätigtes Zeitkonto"
value={formatMinutes(time?.confirmed ?? 0)}
hint="Anfangssaldo, genehmigte Wochen & Korrekturen"
/>
<Stat
label="Verfügbares Zeitguthaben"
value={formatMinutes(time?.available ?? 0)}
hint={`${formatMinutes(time?.reserved ?? 0)} Freizeitausgleich reserviert`}
/>
<Stat
label={`Urlaub verfügbar · ${year}`}
value={vacation ? vacation.available / 2 : "—"}
suffix="Tage"
hint={
vacation
? `${vacation.pending / 2} Tage beantragt`
: "Urlaubskonto noch nicht eingerichtet"
}
/>
<Stat
label="Vorgesehene Woche"
value={formatMinutes(
currentPattern?.dailyMinutes.reduce((s, n) => s + n, 0) ??
0,
)}
hint={
currentPattern
? "Netto-Sollzeit im aktuellen Wochenmodell"
: "Wochenmodell noch nicht eingerichtet"
}
/>
</div>
</>
)}
{profile && tab === "zeit" && (
<div className="grid gap-5">
<Panel
title="Arbeitswoche"
hint="Die Differenz bleibt vorläufig, bis die Woche genehmigt ist."
>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link
aria-label="Vorherige Woche"
href={url({ week: addDays(start, -7) })}
>
←
</Link>
</Button>
<span className="text-sm font-medium">
{shortDate(start)} – {shortDate(end)}
</span>
<Button variant="outline" size="sm" asChild>
<Link
aria-label="Nächste Woche"
href={url({ week: addDays(start, 7) })}
>
→
</Link>
</Button>
</div>
<Badge
variant={
sheet?.status === "APPROVED" ? "success" : "secondary"
}
>
{STATUS_LABELS[sheet?.status ?? "DRAFT"]}
</Badge>
</div>
<form
method="get"
className="mb-4 flex flex-wrap items-end gap-2"
>
<input type="hidden" name="tab" value="zeit" />
{selectedEducator && (
<input
type="hidden"
name="educator"
value={selectedEducator.id}
/>
)}
<label htmlFor="week-picker" className="grid gap-1 text-sm">
Woche mit Datum
<input
id="week-picker"
key={start}
name="week"
type="date"
defaultValue={start}
className={inputClass}
/>
</label>
<Button type="submit" variant="ghost" size="sm">
Anzeigen
</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm">
<thead className="text-[var(--ink-muted)]">
<tr>
{[
"Tag",
"Soll",
"Ist",
"Urlaub / Krankheit",
"Differenz",
].map((s) => (
<th key={s} className="pb-3 pr-4 font-medium">
{s}
</th>
))}
</tr>
</thead>
<tbody>
{days.map((d, i) => (
<tr
key={d.date}
className="border-t border-[var(--hairline)]"
>
<td className="py-3 pr-4">
<span className="font-medium">{DAY_NAMES[i]}</span>
<span className="ml-2 text-xs text-[var(--ink-soft)]">
{shortDate(d.date).slice(0, 5)}
{holidaySet.has(d.date) ? " · Feiertag" : ""}
</span>
{d.timeOff > 0 && (
<p className="mt-1 text-xs text-[var(--accent-deep)]">
{formatMinutes(d.timeOff)} Freizeitausgleich
</p>
)}
</td>
<td>{formatMinutes(d.expected)}</td>
<td>{formatMinutes(d.worked)}</td>
<td>{formatMinutes(d.credit)}</td>
<td
className={
d.balance < 0
? "text-[var(--danger)]"
: "text-[var(--accent-deep)]"
}
>
{formatMinutes(d.balance)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-[var(--hairline)] font-semibold">
<td className="pt-4">Gesamt</td>
<td className="pt-4">
{formatMinutes(totals.expected)}
</td>
<td className="pt-4">{formatMinutes(totals.worked)}</td>
<td className="pt-4">{formatMinutes(totals.credit)}</td>
<td className="pt-4">
{formatMinutes(totals.balance)}
</td>
</tr>
</tfoot>
</table>
</div>
{sheet?.decisionNote && (
<p className="mt-4 rounded-lg bg-[var(--surface-2)] p-3 text-sm">
Rückmeldung: {sheet.decisionNote}
</p>
)}
<div className="mt-5 flex flex-wrap gap-2">
{editable && (
<CommandButton
command="weekSubmit"
profileId={profile.id}
data={{ weekStart: start }}
variant="default"
>
Woche zur Prüfung einreichen
</CommandButton>
)}
{admin &&
profile.userId !== actor.id &&
sheet?.status === "SUBMITTED" && (
<ReviewButton
command="weekReview"
profileId={profile.id}
data={{ weekStart: start }}
approve
label="Woche genehmigen"
/>
)}
{admin &&
profile.userId !== actor.id &&
sheet &&
sheet.status !== "DRAFT" && (
<ReviewButton
command="weekReview"
profileId={profile.id}
data={{ weekStart: start }}
approve={false}
label="Zur Korrektur zurückgeben"
/>
)}
</div>
</Panel>
<Panel
title="Erfasste Arbeitsblöcke"
hint="Pausen werden von der Dauer abgezogen."
>
{weekEntries.length === 0 ? (
<Empty>
Noch keine Arbeitszeiten für diese Woche erfasst.
</Empty>
) : (
<div className="grid gap-3">
{weekEntries.map((e) => (
<div
key={e.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-[var(--surface-2)] p-4"
>
<div>
<p className="text-sm font-medium">
{shortDate(dateKey(e.date))} ·{" "}
{minuteClock(e.startMinute)} –{" "}
{minuteClock(e.endMinute)}
</p>
<p className="mt-1 text-xs text-[var(--ink-soft)]">
{e.breakMinutes} Min. Pause ·{" "}
{formatMinutes(
e.endMinute - e.startMinute - e.breakMinutes,
)}{" "}
netto{e.note ? ` · ${e.note}` : ""}
</p>
</div>
{editable && (
<div className="flex gap-2">
<TimeForm
profileId={profile.id}
initial={{ ...e, date: dateKey(e.date) }}
/>
<CommandButton
command="deleteTime"
profileId={profile.id}
data={{ id: e.id }}
variant="ghost"
>
Löschen
</CommandButton>
</div>
)}
</div>
))}
</div>
)}
</Panel>
<Panel title="Bisherige Wochen">
{profile.weeks.length === 0 ? (
<Empty>
Hier erscheinen eingereichte und genehmigte Wochen.
</Empty>
) : (
<div className="flex flex-wrap gap-2">
{profile.weeks.map((w) => (
<Link
key={w.id}
href={url({ week: dateKey(w.weekStart) })}
className="rounded-lg border border-[var(--hairline)] px-3 py-2 text-sm"
>
{shortDate(dateKey(w.weekStart))} ·{" "}
{STATUS_LABELS[w.status]}
</Link>
))}
</div>
)}
</Panel>
</div>
)}
{profile && tab === "urlaub" && (
<div className="grid gap-5">
<Panel title={`Urlaubskonto ${year}`}>
<form method="get" className="mb-5 flex items-end gap-3">
<input type="hidden" name="tab" value="urlaub" />
{selectedEducator && (
<input
type="hidden"
name="educator"
value={selectedEducator.id}
/>
)}
<label htmlFor="leave-year" className="grid gap-1 text-sm">
Jahr
<input
id="leave-year"
key={year}
name="year"
type="number"
min={2000}
max={2100}
defaultValue={year}
className={`${inputClass} w-28`}
/>
</label>
<Button variant="outline" type="submit">
Anzeigen
</Button>
</form>
{vacation ? (
<div className="grid gap-4 sm:grid-cols-4">
{[
["Anspruch + Übertrag", vacation.entitlement],
["Genommen", vacation.taken],
["Genehmigt / geplant", vacation.planned],
["Offene Anträge", vacation.pending],
].map(([label, units]) => (
<div key={label}>
<p className="text-xs text-[var(--ink-soft)]">
{label}
</p>
<p className="mt-1 text-xl font-medium">
{Number(units) / 2} Tage
</p>
</div>
))}
</div>
) : (
<Empty>
Der Kita-Admin muss den Jahresanspruch hinterlegen.
</Empty>
)}
<p className="mt-4 text-xs text-[var(--ink-soft)]">
Offene Anträge sind noch nicht genehmigt. Sie werden bei
weiteren Anträgen berücksichtigt. Eine offene Stornierung
bleibt bis zur Freigabe gebucht.
</p>
</Panel>
<Panel
title="Meine Abwesenheiten"
hint="Anträge über den Jahreswechsel werden den jeweiligen Jahreskonten zugeordnet."
>
{profile.leaves.length === 0 ? (
<Empty>Noch keine Abwesenheiten erfasst.</Empty>
) : (
<div className="grid gap-4">
{profile.leaves.map((l) => (
<div
key={l.id}
className="rounded-xl border border-[var(--hairline)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-medium">
{KIND_LABELS[l.kind]}{" "}
{l.halfDay ? "· halber Tag" : ""}
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
{shortDate(dateKey(l.startDate))} –{" "}
{shortDate(dateKey(l.endDate))} ·{" "}
{l.kind === "VACATION"
? `${l.days.reduce((s, d) => s + d.vacationUnits, 0) / 2} Tage`
: formatMinutes(
l.days.reduce(
(s, d) => s + d.creditMinutes,
0,
),
)}
</p>
{l.note && <p className="mt-2 text-sm">{l.note}</p>}
{l.decisionNote && (
<p className="mt-2 text-sm text-[var(--ink-soft)]">
Rückmeldung: {l.decisionNote}
</p>
)}
</div>
<Badge
variant={
l.status === "APPROVED" ? "success" : "secondary"
}
>
{STATUS_LABELS[l.status]}
</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{l.status === "PENDING" && (
<LeaveForm
profileId={profile.id}
admin={admin}
initial={{
...l,
startDate: dateKey(l.startDate),
endDate: dateKey(l.endDate),
}}
/>
)}
{["PENDING", "APPROVED"].includes(l.status) && (
<CommandButton
command="leaveCancel"
profileId={profile.id}
data={{ id: l.id }}
variant="ghost"
>
{l.status === "PENDING"
? "Zurückziehen"
: "Stornierung beantragen"}
</CommandButton>
)}
{admin &&
profile.userId !== actor.id &&
["PENDING", "CANCEL_REQUESTED"].includes(
l.status,
) && (
<>
<ReviewButton
command="leaveReview"
profileId={profile.id}
data={{ id: l.id }}
approve
label={
l.status === "CANCEL_REQUESTED"
? "Stornierung freigeben"
: "Genehmigen"
}
/>
<ReviewButton
command="leaveReview"
profileId={profile.id}
data={{ id: l.id }}
approve={false}
label="Ablehnen"
/>
</>
)}
</div>
</div>
))}
</div>
)}
</Panel>
<Panel title="Zeitkorrekturen">
{profile.adjustments.length === 0 ? (
<Empty>Keine manuellen Zeitkorrekturen.</Empty>
) : (
<div className="grid gap-2">
{profile.adjustments
.toSorted((a, b) => b.date.getTime() - a.date.getTime())
.map((a) => (
<p key={a.id} className="text-sm">
{shortDate(dateKey(a.date))} ·{" "}
{formatMinutes(a.minutes)} · {a.reason}
</p>
))}
</div>
)}
</Panel>
</div>
)}
{tab === "kalender" && (
<div className="grid gap-5">
<Panel
title="Interner Teamkalender"
hint="Genehmigte Abwesenheiten. Für KollegInnen erscheint Krankheit nur als Abwesenheit."
>
<form method="get" className="mb-5 flex items-end gap-3">
<input type="hidden" name="tab" value="kalender" />
{selectedEducator && (
<input
type="hidden"
name="educator"
value={selectedEducator.id}
/>
)}
<label className="grid gap-1 text-sm" htmlFor="team-month">
Monat
<input
id="team-month"
key={month}
name="month"
type="month"
defaultValue={month}
className={inputClass}
/>
</label>
<Button variant="outline" type="submit">
Anzeigen
</Button>
</form>
<div className="grid grid-cols-7 gap-1 max-[760px]:hidden">
{DAY_NAMES.map((d) => (
<div
key={d}
className="p-2 text-xs font-semibold text-[var(--ink-soft)]"
>
{d.slice(0, 2)}
</div>
))}
</div>
<div className="grid gap-2 md:grid-cols-7">
{Array.from(
{ length: (dateOnly(monthStart).getUTCDay() + 6) % 7 },
(_, i) => (
<div key={`blank${i}`} className="max-[760px]:hidden" />
),
)}
{datesBetween(monthStart, monthEnd).map((date) => {
const bookings = calendarLeaves.filter((l) =>
l.days.some((d) => dateKey(d.date) === date),
);
const holiday = holidays.find(
(h) => dateKey(h.date) === date,
);
return (
<div
key={date}
className={`min-h-24 rounded-lg border p-2 ${date === today ? "border-[var(--accent)] bg-[var(--accent-soft)]" : "border-[var(--hairline)] bg-[var(--surface-2)]"}`}
>
<p className="mb-2 text-xs font-semibold">
{shortDate(date).slice(0, 5)}
</p>
{holiday && (
<p className="mb-1 text-xs text-[var(--ink-soft)]">
{holiday.name}
</p>
)}
{bookings.map((l) => (
<div
key={l.id}
className="mb-1 rounded bg-[var(--surface)] p-1.5 text-xs leading-4"
>
<p className="font-medium">
{l.profile.educator.firstName}{" "}
{l.profile.educator.lastName}
</p>
<p className="text-[var(--ink-soft)]">
{l.kind === "SICKNESS" && !admin
? "Abwesend"
: KIND_LABELS[l.kind]}
{l.halfDay ? " · ½" : ""}
{l.kind === "TIME_OFF"
? ` · ${formatMinutes(l.days.find((d) => dateKey(d.date) === date)?.creditMinutes ?? 0)}`
: ""}
</p>
</div>
))}
</div>
);
})}
</div>
</Panel>
{admin && (
<Panel
title="Feiertage der Kita"
hint="Separat von Schließtagen: An diesen Tagen entsteht keine Sollzeit und kein Urlaubsverbrauch."
>
<HolidayForm />
<div className="mt-5 grid gap-2">
{holidays
.filter(
(h) =>
h.date.getUTCFullYear() === Number(month.slice(0, 4)),
)
.map((h) => (
<div
key={h.id}
className="flex flex-wrap items-center justify-between gap-2 text-sm"
>
<span>
{shortDate(dateKey(h.date))} · {h.name}
</span>
<CommandButton
command="holiday"
profileId={profile?.id ?? "unused"}
data={{
date: dateKey(h.date),
name: h.name,
remove: true,
}}
variant="ghost"
>
Entfernen
</CommandButton>
</div>
))}
</div>
</Panel>
)}
</div>
)}
{profile && tab === "verwaltung" && (
<div className="grid gap-5">
<Panel title="Beschäftigung & Arbeitsmodell">
<p className="mb-4 text-sm">
Beschäftigung: {shortDate(dateKey(profile.employmentStart))}
{profile.employmentEnd
? ` bis ${shortDate(dateKey(profile.employmentEnd))}`
: " · unbefristet"}{" "}
· Anfangssaldo {formatMinutes(profile.openingMinutes)}
</p>
{profile.workPatterns.length === 0 && (
<Empty>Noch kein Wochenmodell eingerichtet.</Empty>
)}
<div className="grid gap-3">
{profile.workPatterns.map((p) => (
<div
key={p.id}
className="rounded-xl bg-[var(--surface-2)] p-4"
>
<p className="mb-2 text-sm font-medium">
Gültig ab {shortDate(dateKey(p.effectiveFrom))}
</p>
<div className="flex flex-wrap gap-3">
{DAY_NAMES.map((day, i) => (
<span
key={day}
className="text-xs text-[var(--ink-soft)]"
>
{day.slice(0, 2)}:{" "}
{p.dailyMinutes[i]
? formatMinutes(p.dailyMinutes[i])
: "frei"}
</span>
))}
</div>
</div>
))}
</div>
{profile.dayOverrides.length > 0 && (
<div className="mt-4 grid gap-2">
<h3 className="text-sm font-semibold">Tagesausnahmen</h3>
{profile.dayOverrides
.toSorted((a, b) => a.date.getTime() - b.date.getTime())
.map((o) => (
<div
key={o.id}
className="flex flex-wrap items-center justify-between gap-2 text-sm"
>
<span>
{shortDate(dateKey(o.date))} ·{" "}
{formatMinutes(o.minutes)} · {o.reason}
</span>
{admin && (
<CommandButton
command="override"
profileId={profile.id}
data={{
date: dateKey(o.date),
minutes: o.minutes,
reason: "Ausnahme aufgehoben",
remove: true,
}}
variant="ghost"
>
Aufheben
</CommandButton>
)}
</div>
))}
</div>
)}
</Panel>
{admin && (
<SettingsPanel
key={`${profile.id}-${year}`}
profileId={profile.id}
educatorId={profile.educatorId}
employment={{
employmentStart: dateKey(profile.employmentStart),
employmentEnd: profile.employmentEnd
? dateKey(profile.employmentEnd)
: null,
trackingStart: dateKey(profile.trackingStart),
openingMinutes: profile.openingMinutes,
}}
email={profile.user?.email}
verified={!!profile.user?.emailVerifiedAt}
pattern={
profile.workPatterns.at(-1)
? {
effectiveFrom: dateKey(
profile.workPatterns.at(-1)!.effectiveFrom,
),
dailyMinutes:
profile.workPatterns.at(-1)!.dailyMinutes,
}
: undefined
}
year={year}
account={account}
/>
)}
<Panel
title="Verlauf"
hint="Die letzten 30 Änderungen an diesem Personalprofil."
>
{profile.history.length === 0 ? (
<Empty>Noch keine Änderungen.</Empty>
) : (
<div className="grid gap-3">
{profile.history.map((h) => (
<div
key={h.id}
className="border-b border-[var(--hairline)] pb-3"
>
<p className="text-sm font-medium">
{h.action}{" "}
<span className="ml-2 text-xs font-normal text-[var(--ink-soft)]">
{new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
dateStyle: "short",
timeStyle: "short",
}).format(h.createdAt)}
</span>
</p>
<p className="mt-1 break-words text-xs text-[var(--ink-soft)]">
{h.details}
</p>
</div>
))}
</div>
)}
</Panel>
</div>
)}
</>
)}
</div>
);
}