Karenz was doing duty as the name for every kind of extended absence, but the cases behave differently in payroll and reporting — Wochenhilfe, a Präsenzdienst, a long sick leave and a sabbatical are not the same thing. The concept is now called Langzeitabwesenheit and carries which kind it is. - employees.absence_type, constrained to the thirteen kinds. start_karenz stores it on both paths (written straight away, or parked in the pending_org_changes payload when the absence starts later); record_karenz_return and the karenz_return branch of apply_due_pending_changes clear it, so a returned employee does not keep looking like they are still away. It also reaches employee_history, the audit log and the employee export. - The status enum value stays 'Karenz'. Postgres can rename an enum value in place, but every stored function body that spells it would then reference a value that no longer exists — a dozen functions across fifteen migrations, rewritten for a label. The mapping lives in lib/absence.ts instead, which is the single place the UI reads the display name from. - Where a kind is recorded the chip shows it — "Bildungskarenz" says more than "Langzeitabwesenheit". Absences predating the field have none and fall back to the generic name rather than to a guess, and a value outside the list is dropped rather than echoed into the UI. - The export prints the display name, not the raw enum: a payroll hand-off reading "Karenz" for what the app calls Langzeitabwesenheit only causes questions. Audit filter options keep their stored values and change only their labels. - The seed spreads the twelve absences across the kinds; all of them being Karenz would leave any breakdown by kind invisible.
315 lines
14 KiB
TypeScript
315 lines
14 KiB
TypeScript
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<string, { text: string; bar: string }> = {
|
|
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<string, string> = {
|
|
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() {
|
|
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<string, number>();
|
|
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: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
|
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
{kpis.map((kpi) => (
|
|
<Link
|
|
key={kpi.label}
|
|
href={kpi.href}
|
|
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
|
>
|
|
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
|
{/* Number first in the visual order: it is what the eye is
|
|
looking for, and the label only qualifies it. */}
|
|
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
|
|
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
|
{kpi.label}
|
|
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<Card>
|
|
{/* Aktive, not headcount: the bars count the same set as the tile
|
|
above them, which excludes Karenz. */}
|
|
<CardTitle className="mb-3">Aktive nach Bereich</CardTitle>
|
|
<div className="flex flex-col gap-2.5">
|
|
{divisionBars.map((d) => (
|
|
<div key={d.name}>
|
|
<div className="mb-1 flex justify-between text-xs">
|
|
<span className="text-ink-body">{d.name}</span>
|
|
<span className="font-semibold tabular-nums text-ink">{d.count}</span>
|
|
</div>
|
|
{/* Rounded ends and a minimum width so the smallest division
|
|
still reads as a bar rather than a stray pixel. */}
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface">
|
|
<div
|
|
className="h-full rounded-full bg-brand-500"
|
|
style={{ width: `${Math.max(2, (d.count / maxDivisionCount) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{divisionBars.length === 0 && <p className="text-sm text-ink-muted">Keine Daten vorhanden.</p>}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
|
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
|
{upcoming.map((item) => (
|
|
<li key={`${item.kind}-${item.id}`}>
|
|
<Link
|
|
href={`/employees/${item.id}`}
|
|
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
|
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
|
</span>
|
|
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
|
</ul>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
|
{(historyRes.data ?? []).map((h) => (
|
|
<li key={h.id} className="flex gap-2.5 py-2.5">
|
|
{/* Dot aligned to the first line of text, not centred on the
|
|
whole row, so it stays put as descriptions wrap. */}
|
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
|
{h.event_type}
|
|
</span>
|
|
</div>
|
|
<p className="mt-0.5 text-xs leading-relaxed text-ink-muted">{h.description}</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
|
</ul>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|