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 (

TEAM · PERSONALBEREICH

{admin ? "Personal &" : "Meine"}{" "} Arbeitszeit

Feste Arbeitstage, erfasste Stunden und Abwesenheiten an einem Ort.

{profile && (
)}
{pendingEmails > 0 && (

{pendingEmails} E-Mail-Hinweise warten noch auf den Versand.

Versand erneut versuchen
)} {notifications.length > 0 && (
{notifications.length} neue Hinweise
{notifications.map((n) => (

{n.message}

Gelesen
))}
)} {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"]}
{selectedEducator && ( )}
{[ "Tag", "Soll", "Ist", "Urlaub / Krankheit", "Differenz", ].map((s) => ( ))} {days.map((d, i) => ( ))}
{s}
{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" && (
{selectedEducator && ( )}
{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" && (
{selectedEducator && ( )}
{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}

))}
)}
)} )}
); }