import { ChevronRight } from "lucide-react"; import Link from "next/link"; import { Suspense } from "react"; import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter"; import { AnstehendListe, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe"; import { DraftsCard } from "@/components/dashboard/DraftsCard"; import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card"; import { actionBadgeStyle } from "@/lib/colors"; import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter"; import { loadDashboardData } from "@/lib/dashboard-data"; import { addDaysIso, fmtName, todayIso } from "@/lib/format"; import { divisionOf } from "@/lib/org"; import { deriveStatusAsOf } from "@/lib/reports"; import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; // Each KPI carries a colour already; the accent bar repeats it in a second // channel so the tiles are scannable as a row rather than six identical // boxes, and so the meaning does not rest on hue alone. const TONE: Record = { default: { text: "text-ink", bar: "bg-ink-muted" }, success: { text: "text-success-text", bar: "bg-success-text" }, danger: { text: "text-danger-text", bar: "bg-danger-text" }, warning: { text: "text-warning-text", bar: "bg-warning-text" }, brand: { text: "text-brand-700", bar: "bg-brand-500" }, }; const DOT_STYLES: Record = { Eintritt: "bg-success-text", Wiedereintritt: "bg-success-text", Rückkehr: "bg-success-text", Austritt: "bg-danger-text", Versetzung: "bg-info-text", Beförderung: "bg-purple-text", Reorganisation: "bg-purple-text", Karenz: "bg-warning-text", Vertragsänderung: "bg-warning-text", Stammdatenänderung: "bg-warning-text", Gehaltsanpassung: "bg-warning-text", }; export default async function DashboardPage({ searchParams, }: { searchParams: Promise<{ tage?: string; arten?: string }>; }) { // Built as strings, not by round-tripping a local Date through // toISOString(): in any positive-offset zone new Date(year, 0, 1) is still // the previous year in UTC, which shifted the whole YTD window a day early // and dropped 31 December from it entirely. const today = todayIso(); const year = today.slice(0, 4); const yearStart = `${year}-01-01`; const yearEnd = `${year}-12-31`; // Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen // wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90 // statt 60 Tage bringt Zeilen ins Spiel, die sonst nirgends lägen. const params = await searchParams; const zeitraum = parseZeitraum(params.tage); const arten = parseArten(params.arten); const bisIso = addDaysIso(today, zeitraum); const userId = await currentUserId(); // Headcount, FTE, Karenz and the division bars all come from one full read // and the *derived* status, not from the `employees.status` column. // // That column only ever reflects what the last mutation or cron run wrote, // while every report derives status from entry/exit/karenz dates — so a // planned hire whose start date has passed, or a Karenz that ended without // anyone recording the return, made the dashboard and the Berichte page // disagree about the same headcount. Same derivation, same numbers. const { drafts, staffRows, hiresYtd, exitsYtd, openPositions, orgMaps, placements, upcomingHires, upcomingExits, upcomingReturns, upcomingNotes, history, } = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten })); // "Aktiv" means status Aktiv — somebody on Karenz is employed but not // active, and is counted by its own tile instead. FTE follows the same // set: Karenz contributes no capacity, so including it would overstate // what the company can actually staff. // // Note this is narrower than DEFAULT_STATUSES in lib/reports (Aktiv + // Karenz), which still governs what the Berichte page shows when no // status filter is chosen. const statusOf = (row: (typeof staffRows)[number]) => deriveStatusAsOf(row, today); const activeStaff = staffRows.filter((row) => statusOf(row) === "Aktiv"); const activeCount = activeStaff.length; const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length; const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5; // Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der // Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst // sitzt *am* Bereich, ihre Leute darunter — beide landen über die // Vorfahrenkette im selben Balken. const headcountByDivision = new Map(); for (const row of activeStaff) { const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId); if (!division) continue; headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1); } const divisionBars = orgMaps.unitList .filter((u) => u.unit_type === "Bereich") .map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 })) .sort((a, b) => b.count - a.count); const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count)); const upcomingAlle: AnstehendEintrag[] = [ ...(upcomingHires).map((e) => ({ id: e.id, employeeId: e.id, label: fmtName(e.first_name, e.last_name), date: e.entry_date, kind: "hire" as const, })), ...(upcomingExits).map((e) => ({ id: e.id, employeeId: e.id, label: fmtName(e.first_name, e.last_name), date: e.exit_date!, kind: "exit" as const, })), ...(upcomingReturns).map((e) => ({ id: e.id, employeeId: e.id, label: fmtName(e.first_name, e.last_name), date: e.karenz_return_date!, kind: "return" as const, })), ...(upcomingNotes).map((n) => ({ id: n.id, employeeId: n.employee_id!, label: fmtName(n.first_name, n.last_name), hinweis: n.note_text, date: n.due_date!, kind: "note" as const, })), ].sort((a, b) => a.date.localeCompare(b.date)); // Each tile links to the view that shows what it counts, with the filters // pre-applied. // // Two of them cannot match exactly, and it is worth knowing which: the // headcount tiles filter `employees` and their targets filter the same // table, so those agree. Eintritte/Austritte count `employees.entry_date` // / `exit_date`, while the events report counts `employee_history` rows — // and rehire_employee sets entry_date but logs the event as // 'Wiedereintritt'. A year with rehires therefore shows a slightly higher // number on the tile than in the linked report. const kpis = [ { label: "Aktive Mitarbeiter:innen", value: activeCount, tone: "default", href: "/employees?status=Aktiv", }, { label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" }, { label: "Eintritte (Jahr)", value: hiresYtd, tone: "success", href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Austritte (Jahr)", value: exitsYtd, tone: "danger", href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, { label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }, ]; return (
{drafts.length > 0 && }
{kpis.map((kpi) => ( {/* Number first in the visual order: it is what the eye is looking for, and the label only qualifies it. */}
{kpi.value}
{kpi.label}
))}
{/* Aktive, not headcount: the bars count the same set as the tile above them, which excludes Karenz. */} Aktive nach Bereich
{divisionBars.map((d) => (
{d.name} {d.count}
{/* Rounded ends and a minimum width so the smallest division still reads as a bar rather than a stray pixel. */}
))} {divisionBars.length === 0 &&

Keine Daten vorhanden.

}
Anstehend ({zeitraum} Tage) {/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */} }> Letzte Aktivitäten
    {(history).map((h) => (
  • {/* Dot aligned to the first line of text, not centred on the whole row, so it stays put as descriptions wrap. */}
    {h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"} {h.event_type}

    {h.description}

  • ))} {(history).length === 0 &&

    Keine Aktivitäten vorhanden.

    }
); }