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 { deriveStatusAsOf } from "@/lib/reports"; import { createClient } from "@/lib/supabase/server"; import { fetchAllRows } from "@/lib/supabase/query"; // 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: "Karenz-Rückkehr" } as const; export default async function DashboardPage() { const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); const { data: drafts } = user ? await supabase .from("hire_drafts") .select("id, step, payload, updated_at") .eq("created_by", user.id) .order("updated_at", { ascending: false }) : { data: [] }; // 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); // 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. // It also replaces four separate count queries with one. const [ staffRows, hiresYtdRes, exitsYtdRes, openPositionsRes, divisionsRes, upcomingHiresRes, upcomingExitsRes, upcomingReturnsRes, historyRes, ] = await Promise.all([ fetchAllRows(() => supabase .from("employees") .select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date") .order("id") ), // 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. supabase .from("employee_history") .select("id", { count: "exact", head: true }) .in("event_type", ["Eintritt", "Wiedereintritt"]) .gte("event_date", yearStart) .lte("event_date", yearEnd), supabase .from("employee_history") .select("id", { count: "exact", head: true }) .eq("event_type", "Austritt") .gte("event_date", yearStart) .lte("event_date", yearEnd), supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"), supabase.from("divisions").select("id, name"), supabase .from("employees") .select("id, first_name, last_name, entry_date") .eq("status", "Geplant") .gte("entry_date", today) .lte("entry_date", in60Iso), supabase .from("employees") .select("id, first_name, last_name, exit_date") .not("exit_date", "is", null) .gte("exit_date", today) .lte("exit_date", in60Iso), supabase .from("employees") .select("id, first_name, last_name, karenz_return_date") .eq("status", "Karenz") .not("karenz_return_date", "is", null) .gte("karenz_return_date", today) .lte("karenz_return_date", in60Iso), supabase .from("employee_history") .select("id, employee_id, event_date, event_type, description") .order("event_date", { ascending: false }) .order("created_at", { ascending: false }) .limit(10), ]); // "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; const headcountByDivision = new Map(); for (const row of activeStaff) { if (!row.division_id) continue; headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1); } const divisionBars = (divisionsRes.data ?? []) .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[] = [ ...(upcomingHiresRes.data ?? []).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.entry_date, kind: "hire" as const, })), ...(upcomingExitsRes.data ?? []).map((e) => ({ id: e.id, label: `${e.first_name} ${e.last_name}`, date: e.exit_date!, kind: "exit" as const, })), ...(upcomingReturnsRes.data ?? []).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); const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id))); const historyEmployeesRes = historyEmployeeIds.length ? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds) : { data: [] as { id: string; first_name: string; last_name: string }[] }; const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`])); // 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: hiresYtdRes.count ?? 0, tone: "success", href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Austritte (Jahr)", value: exitsYtdRes.count ?? 0, tone: "danger", href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, }, { label: "In Karenz", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, { label: "Offene Positionen", value: openPositionsRes.count ?? 0, 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
    {(historyRes.data ?? []).map((h) => (
  • {/* Dot aligned to the first line of text, not centred on the whole row, so it stays put as descriptions wrap. */}
    {employeeNameById.get(h.employee_id) ?? "Unbekannt"} {h.event_type}

    {h.description}

  • ))} {(historyRes.data ?? []).length === 0 &&

    Keine Aktivitäten vorhanden.

    }
); }