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 (
{title}
{hint && {hint}
}
{children}
);
}
function Empty({ children }: { children: ReactNode }) {
return (
{children}
);
}
export default async function PersonalPage({
searchParams,
}: {
searchParams: Promise>;
}) {
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) => {
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 (
{pendingEmails > 0 && (
{pendingEmails} E-Mail-Hinweise warten noch auf den Versand.
Versand erneut versuchen
)}
{notifications.length > 0 && (
{notifications.length} neue Hinweise
{notifications.map((n) => (
))}
)}
{admin && (
)}
{tab === "team" && admin ? (
e.staffProfile).length}
hint={`${educators.length} Personen im Personalstamm`}
/>
{educators.length === 0 && (
Bitte zuerst eine Fachkraft im Personalstamm anlegen.
)}
{educators.map((e) => (
{e.firstName} {e.lastName}
{!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"} →`}
))}
{pendingLeaves.length === 0 ? (
Alle Abwesenheitsanträge sind bearbeitet.
) : (
{pendingLeaves.map((l) => (
{l.profile.educator.firstName}{" "}
{l.profile.educator.lastName}
{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)}
{l.note && (
{l.note}
)}
{l.profile.userId !== actor.id && (
)}
))}
)}
{pendingWeeks.length === 0 ? (
Keine eingereichten Wochen zur Prüfung.
) : (
{pendingWeeks.map((w) => (
{w.profile.educator.firstName}{" "}
{w.profile.educator.lastName} · Woche ab{" "}
{shortDate(dateKey(w.weekStart))} prüfen →
))}
)}
) : (
<>
{!profile && tab !== "kalender" && (
{admin && selectedEducator ? (
) : (
{admin
? "Lege zuerst eine Fachkraft im Personalstamm an."
: "Bitte den Kita-Admin bitten, deinen Account mit deinem Personalprofil zu verknüpfen."}
)}
)}
{profile && tab !== "kalender" && (
<>
{profile.educator.firstName} {profile.educator.lastName}{" "}
· Erfassung seit {shortDate(dateKey(profile.trackingStart))}
s + n, 0) ??
0,
)}
hint={
currentPattern
? "Netto-Sollzeit im aktuellen Wochenmodell"
: "Wochenmodell noch nicht eingerichtet"
}
/>
>
)}
{profile && tab === "zeit" && (
{shortDate(start)} – {shortDate(end)}
{STATUS_LABELS[sheet?.status ?? "DRAFT"]}
{[
"Tag",
"Soll",
"Ist",
"Urlaub / Krankheit",
"Differenz",
].map((s) => (
|
{s}
|
))}
{days.map((d, i) => (
|
{DAY_NAMES[i]}
{shortDate(d.date).slice(0, 5)}
{holidaySet.has(d.date) ? " · Feiertag" : ""}
{d.timeOff > 0 && (
{formatMinutes(d.timeOff)} Freizeitausgleich
)}
|
{formatMinutes(d.expected)} |
{formatMinutes(d.worked)} |
{formatMinutes(d.credit)} |
{formatMinutes(d.balance)}
|
))}
| Gesamt |
{formatMinutes(totals.expected)}
|
{formatMinutes(totals.worked)} |
{formatMinutes(totals.credit)} |
{formatMinutes(totals.balance)}
|
{sheet?.decisionNote && (
Rückmeldung: {sheet.decisionNote}
)}
{editable && (
Woche zur Prüfung einreichen
)}
{admin &&
profile.userId !== actor.id &&
sheet?.status === "SUBMITTED" && (
)}
{admin &&
profile.userId !== actor.id &&
sheet &&
sheet.status !== "DRAFT" && (
)}
{weekEntries.length === 0 ? (
Noch keine Arbeitszeiten für diese Woche erfasst.
) : (
{weekEntries.map((e) => (
{shortDate(dateKey(e.date))} ·{" "}
{minuteClock(e.startMinute)} –{" "}
{minuteClock(e.endMinute)}
{e.breakMinutes} Min. Pause ·{" "}
{formatMinutes(
e.endMinute - e.startMinute - e.breakMinutes,
)}{" "}
netto{e.note ? ` · ${e.note}` : ""}
{editable && (
Löschen
)}
))}
)}
{profile.weeks.length === 0 ? (
Hier erscheinen eingereichte und genehmigte Wochen.
) : (
{profile.weeks.map((w) => (
{shortDate(dateKey(w.weekStart))} ·{" "}
{STATUS_LABELS[w.status]}
))}
)}
)}
{profile && tab === "urlaub" && (
{vacation ? (
{[
["Anspruch + Übertrag", vacation.entitlement],
["Genommen", vacation.taken],
["Genehmigt / geplant", vacation.planned],
["Offene Anträge", vacation.pending],
].map(([label, units]) => (
{label}
{Number(units) / 2} Tage
))}
) : (
Der Kita-Admin muss den Jahresanspruch hinterlegen.
)}
Offene Anträge sind noch nicht genehmigt. Sie werden bei
weiteren Anträgen berücksichtigt. Eine offene Stornierung
bleibt bis zur Freigabe gebucht.
{profile.leaves.length === 0 ? (
Noch keine Abwesenheiten erfasst.
) : (
{profile.leaves.map((l) => (
{KIND_LABELS[l.kind]}{" "}
{l.halfDay ? "· halber Tag" : ""}
{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,
),
)}
{l.note &&
{l.note}
}
{l.decisionNote && (
Rückmeldung: {l.decisionNote}
)}
{STATUS_LABELS[l.status]}
{l.status === "PENDING" && (
)}
{["PENDING", "APPROVED"].includes(l.status) && (
{l.status === "PENDING"
? "Zurückziehen"
: "Stornierung beantragen"}
)}
{admin &&
profile.userId !== actor.id &&
["PENDING", "CANCEL_REQUESTED"].includes(
l.status,
) && (
<>
>
)}
))}
)}
{profile.adjustments.length === 0 ? (
Keine manuellen Zeitkorrekturen.
) : (
{profile.adjustments
.toSorted((a, b) => b.date.getTime() - a.date.getTime())
.map((a) => (
{shortDate(dateKey(a.date))} ·{" "}
{formatMinutes(a.minutes)} · {a.reason}
))}
)}
)}
{tab === "kalender" && (
{DAY_NAMES.map((d) => (
{d.slice(0, 2)}
))}
{Array.from(
{ length: (dateOnly(monthStart).getUTCDay() + 6) % 7 },
(_, i) => (
),
)}
{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 (
{shortDate(date).slice(0, 5)}
{holiday && (
{holiday.name}
)}
{bookings.map((l) => (
{l.profile.educator.firstName}{" "}
{l.profile.educator.lastName}
{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)}`
: ""}
))}
);
})}
{admin && (
{holidays
.filter(
(h) =>
h.date.getUTCFullYear() === Number(month.slice(0, 4)),
)
.map((h) => (
{shortDate(dateKey(h.date))} · {h.name}
Entfernen
))}
)}
)}
{profile && tab === "verwaltung" && (
Beschäftigung: {shortDate(dateKey(profile.employmentStart))}
{profile.employmentEnd
? ` bis ${shortDate(dateKey(profile.employmentEnd))}`
: " · unbefristet"}{" "}
· Anfangssaldo {formatMinutes(profile.openingMinutes)}
{profile.workPatterns.length === 0 && (
Noch kein Wochenmodell eingerichtet.
)}
{profile.workPatterns.map((p) => (
Gültig ab {shortDate(dateKey(p.effectiveFrom))}
{DAY_NAMES.map((day, i) => (
{day.slice(0, 2)}:{" "}
{p.dailyMinutes[i]
? formatMinutes(p.dailyMinutes[i])
: "frei"}
))}
))}
{profile.dayOverrides.length > 0 && (
Tagesausnahmen
{profile.dayOverrides
.toSorted((a, b) => a.date.getTime() - b.date.getTime())
.map((o) => (
{shortDate(dateKey(o.date))} ·{" "}
{formatMinutes(o.minutes)} · {o.reason}
{admin && (
Aufheben
)}
))}
)}
{admin && (
)}
{profile.history.length === 0 ? (
Noch keine Änderungen.
) : (
{profile.history.map((h) => (
{h.action}{" "}
{new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
dateStyle: "short",
timeStyle: "short",
}).format(h.createdAt)}
{h.details}
))}
)}
)}
>
)}
);
}