Files
alpenwerk-hr/app/(app)/page.tsx
Maximilian Stubhan eb25369d1d
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m35s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m14s
Let the truncated Anstehend list open
"… und 5 weitere Ereignisse in diesem Zeitraum" was a sentence to read.
It is now a button: click it and the rest unfolds in place, click again
and the card goes back to eight rows.

Until now the only way to see the remaining entries was to narrow the
period or the kinds — which changes the question rather than the answer.

This happens in the browser, not through the URL, unlike the period and
kind filters. Those go through the address because a longer period brings
rows into play that were never loaded; here every entry in the period is
already on the page and the eight was purely presentational. A round trip
would mean waiting for data that is already there, plus a history entry
for something nobody wants to go back to.

The list moved into its own component so the state has somewhere to live.
KIND_LABEL and the item type moved with it, since they only describe this
list.

The button only appears when there is something to unfold, and once open
it offers the way back — otherwise the card stays long for the rest of the
session because somebody looked once.

Verified: 10 tests, five mutation-checked (ignoring the state, dropping
the way back, showing the button with nothing to unfold, losing the
singular, dropping aria-expanded each turn them red). 487 tests,
typecheck and build clean. Not seen in a browser — login goes through the
company account and the database is unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:20:19 +02:00

276 lines
12 KiB
TypeScript

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<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",
};
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<string, number>();
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 (
<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-2">Anstehend ({zeitraum} Tage)</CardTitle>
{/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim
Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */}
<Suspense fallback={<div className="mb-2 h-9" />}>
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
</Suspense>
<AnstehendListe
eintraege={upcomingAlle}
today={today}
leerText={
istEingeschraenkt(zeitraum, arten)
? "Zu dieser Auswahl steht nichts an."
: "Keine anstehenden Ereignisse."
}
/>
</Card>
<Card>
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
<ul className="flex flex-col divide-y divide-border-subtle">
{(history).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">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "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>
))}
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
</ul>
</Card>
</div>
</div>
);
}