1265 lines
49 KiB
TypeScript
1265 lines
49 KiB
TypeScript
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>
|
||
);
|
||
}
|