Visual - `--radius: 8px` in @theme collapsed Tailwind v4's whole radius scale onto a single value: `rounded` and `rounded-lg` both measured 8px, so a chip, an input and a card could not be told apart. Named steps restore the gradation (6 / 8 / 12px, measured in the browser). - Cards were a 1px border and nothing else. Added warm, brand-tinted elevation tokens — a neutral black shadow over the pink surface reads as dirt — in three steps for cards, dropdowns and overlays, collected behind components/ui/Card.tsx so the 26 hand-copied card class chains have one definition. - KPI tiles lead with the number and carry a tone accent; tables got denser rows, subtle row rules (the full border strength made 800 rows read as a grid), tabular figures in numeric columns and a brand-tinted hover. KPI tiles now link to the view that shows what they count. Making those links honest surfaced two reasons the numbers did not agree with their destinations: - The dashboard read `employees.status`, while every report derives status from entry/exit/karenz dates. A hire whose start date had passed before the cron ran was counted differently on the two pages. The dashboard now uses the same derivation — and one query instead of five. - Eintritte/Austritte counted `entry_date`/`exit_date` while the linked report counts `employee_history`; rehire_employee sets entry_date but logs the event as 'Wiedereintritt', so rehires were missing from the target. Both now count history events. - The employee list filtered on the status column, so it disagreed too. It now filters on derived status in SQL (lib/employee-status-filter.ts). That restates deriveStatusAsOf a second time, in a second language, so an integration test runs both over the full roster and requires identical id sets — drift here is otherwise invisible. Status semantics, per the domain correction: "aktiv" means status Aktiv alone. Karenz is employed but not active, and has its own tile. The active headcount, FTE (Karenz contributes no capacity) and the division bars all follow that; the bars are labelled "Aktive nach Bereich" rather than "Headcount" to say so. The employee filter still offers the combination, named after the two statuses it selects instead of calling the pair active. DEFAULT_STATUSES in lib/reports.ts is deliberately left at Aktiv + Karenz: it governs what the Berichte page shows without an explicit status filter, and therefore what already-saved reports and exports mean.
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: "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<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: "In Karenz", 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>
|
|
);
|
|
}
|