import { ChevronRight } from "lucide-react"; import Link from "next/link"; import { DraftsCard } from "@/components/dashboard/DraftsCard"; import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card"; import { actionBadgeStyle } from "@/lib/colors"; import { addDaysIso, fmtDate, todayIso } from "@/lib/format"; import { divisionOf, loadOrgMaps } from "@/lib/org"; import { loadPlacements } from "@/lib/placement"; import { loadOpenPositions } from "@/lib/positions"; import { deriveStatusAsOf } from "@/lib/reports"; import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; import type { HistoryEventType } from "@/lib/supabase/types"; // 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", }; const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const; export default async function DashboardPage() { // 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`; const in60Iso = addDaysIso(today, 60); 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, history, } = await withUser(userId, async (tx) => { const countIn = (types: readonly HistoryEventType[]) => tx .selectFrom("employee_history") .select(({ fn }) => fn.countAll().as("anzahl")) .where("event_type", "in", [...types]) .where("event_date", ">=", yearStart) .where("event_date", "<=", yearEnd) .executeTakeFirst(); const [ drafts, staffRows, hiresYtd, exitsYtd, openPositions, orgMaps, placements, upcomingHires, upcomingExits, upcomingReturns, history, ] = await Promise.all([ userId ? tx .selectFrom("hire_drafts") .select(["id", "step", "payload", "updated_at"]) .where("created_by", "=", userId) .orderBy("updated_at", "desc") .execute() : Promise.resolve([]), tx .selectFrom("employees") .select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"]) .orderBy("id") .execute(), // Entries/exits count history events, which is what the linked report // counts too. `entry_date` would also sweep up rehires, whose event is // logged as 'Wiedereintritt' — the tile and its destination then showed // different numbers for the same year. countIn(["Eintritt", "Wiedereintritt"]), countIn(["Austritt"]), loadOpenPositions(tx), loadOrgMaps(tx), loadPlacements(tx, { asOf: today }), tx .selectFrom("employees") .select(["id", "first_name", "last_name", "entry_date"]) .where("status", "=", "Geplant") .where("entry_date", ">=", today) .where("entry_date", "<=", in60Iso) .execute(), tx .selectFrom("employees") .select(["id", "first_name", "last_name", "exit_date"]) .where("exit_date", "is not", null) .where("exit_date", ">=", today) .where("exit_date", "<=", in60Iso) .execute(), tx .selectFrom("employees") .select(["id", "first_name", "last_name", "karenz_return_date"]) .where("status", "=", "Karenz") .where("karenz_return_date", "is not", null) .where("karenz_return_date", ">=", today) .where("karenz_return_date", "<=", in60Iso) .execute(), tx .selectFrom("employee_history as h") .leftJoin("employees as e", "e.id", "h.employee_id") .select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"]) .orderBy("h.event_date", "desc") .orderBy("h.created_at", "desc") .limit(10) .execute(), ]); return { drafts, staffRows, hiresYtd: Number(hiresYtd?.anzahl ?? 0), exitsYtd: Number(exitsYtd?.anzahl ?? 0), openPositions, orgMaps, placements, upcomingHires, upcomingExits, upcomingReturns, history, }; }); // "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)); type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL }; const upcoming: UpcomingItem[] = [ ...(upcomingHires).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.entry_date, kind: "hire" as const, })), ...(upcomingExits).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.exit_date!, kind: "exit" as const, })), ...(upcomingReturns).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.karenz_return_date!, kind: "return" as const, })), ] .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 8); // 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 && 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 (60 Tage)
    {upcoming.map((item) => (
  • {item.label} {KIND_LABEL[item.kind]} {fmtDate(item.date)}
  • ))} {upcoming.length === 0 &&

    Keine anstehenden Ereignisse.

    }
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 ? `${h.first_name} ${h.last_name}` : "Unbekannt"} {h.event_type}

    {h.description}

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

    Keine Aktivitäten vorhanden.

    }
); }