In "Letzte Aktivitaeten" stand "Eintritt" weiter auf Rosa, waehrend die Karte daneben ihn laengst gruen zeigte. Dieselbe Ursache wie bei den Anstehend-Chips, nur eine Ecke weiter: die Uebersicht zeigt Ereignisse aus employee_history, holte ihre Farbe aber aus ACTION_CATEGORY — und das ist die Sprache des Protokolls. Dort heisst es "Neueinstellung" und "Wiedereinstellung", in der Historie "Eintritt" und "Wiedereintritt". Genau diese zwei von elf standen nicht darin und fielen auf den neutralen Chip zurueck; die uebrigen neun trafen zufaellig. EVENT_CATEGORY ist jetzt die Zuordnung fuer die Historie, als Record<HistoryEventType, …> und damit vollzaehlig: ein zwoelftes Ereignis laesst der Typpruefer nicht durch, ohne dass jemand eine Farbe dafuer bestimmt. Ein Nachschlagen mit Rueckfall haette auch dann wieder still etwas Plausibles geliefert. Betroffen war nicht nur die Uebersicht — der Historie-Reiter in der Personalakte faerbte seine Chips und seine Filterknoepfe aus derselben falschen Tabelle. Auch die sind umgestellt. Die Punkte vor den Zeilen lagen in einer zweiten Tabelle in page.tsx und sagten fuer "Eintritt" bereits gruen — Punkt und Chip derselben Zeile kamen also aus zwei Verzeichnissen, von denen eines das falsche war. Beide leiten jetzt aus EVENT_CATEGORY ab. Die Rueckkehr ist dabei violett geworden, auch in der Historie: auf der Uebersicht steht sie neben dem Eintritt, und zwei Gruentoene nebeneinander sind keine zwei Dinge. ANSTEHEND_STYLES leitet fuer Eintritt, Austritt und Rueckkehr aus derselben Tabelle ab — die beiden Karten koennen nicht mehr auseinanderlaufen. ACTION_CATEGORY behaelt seinen Rueckfall, und das bleibt richtig: die Aktionen schreiben die SQL-Funktionen als freien Text, eine neue kann jederzeit dazukommen, und ihr neutraler Chip ist dann eine ehrliche Aussage. Fuer eine geschlossene Aufzaehlung war derselbe Rueckfall ein Fehler. Acht Tests, aus EVENT_TYPE_LABELS abgeleitet statt abgeschrieben: dass jedes Ereignis eine Farbe hat, dass keines den neutralen Chip bekommt, dass Punkt und Chip derselben Zeile zusammenpassen und dass die beiden Karten der Uebersicht dasselbe meinen. Lint, Typen, Schemaabgleich, 575 Tests und der Build sind sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
12 KiB
TypeScript
267 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 { eventBadgeStyle, eventDotStyle } 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" },
|
|
};
|
|
|
|
// Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und
|
|
// Chip derselben Zeile kamen aus zwei getrennten Verzeichnissen, und eines
|
|
// davon war das falsche. Jetzt leiten beide aus EVENT_CATEGORY ab und koennen
|
|
// nicht mehr auseinanderlaufen.
|
|
|
|
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.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 ${eventDotStyle(h.event_type)}`} 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 ${eventBadgeStyle(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>
|
|
);
|
|
}
|