Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel "Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte (Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn. "Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der Behinderung und der Erklaertext ueber dem Honestly-Report sind weg. "Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler. Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern" wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse. "Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer "Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt. Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige. Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends. Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis 20260814100000 stand sie in der Beschreibung des Ereignisses; beim Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung faengt den naechsten Verlust ab.
396 lines
18 KiB
TypeScript
396 lines
18 KiB
TypeScript
import { ChevronRight } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { Fragment, 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" },
|
|
// Geplante Eintritte: eine Ankündigung, kein Befund. „info" ist der Ton,
|
|
// den die Statuschips für „Geplant" ohnehin tragen.
|
|
info: { text: "text-info-text", bar: "bg-info-text" },
|
|
};
|
|
|
|
/** Eine Kennzahl auf der Übersicht. */
|
|
type Kachel = { label: string; value: string | number; tone: keyof typeof TONE; href: string };
|
|
|
|
/**
|
|
* Wie viele Spalten die Überschrift einer Gruppe überspannt — nach der Zahl
|
|
* ihrer Kacheln.
|
|
*
|
|
* Ausgeschrieben und nicht als `span var(--kacheln)`: Tailwind erzeugt nur
|
|
* Klassen, die als Zeichenkette im Quelltext stehen, und eine aus einer
|
|
* Variablen berechnete Spannweite wäre zur Bauzeit nicht zu sehen. Die
|
|
* Tabelle deckt jede mögliche Gruppengrösse bis zur Rasterbreite ab; ein
|
|
* Zugriff daneben gäbe `undefined` und damit eine Überschrift über einer
|
|
* einzigen Spalte — auffällig genug, um bemerkt zu werden.
|
|
*/
|
|
const SPALTEN_SPAN: Record<number, string> = {
|
|
1: "xl:col-span-1",
|
|
2: "xl:col-span-2",
|
|
3: "xl:col-span-3",
|
|
4: "xl:col-span-4",
|
|
5: "xl:col-span-5",
|
|
6: "xl:col-span-6",
|
|
7: "xl:col-span-7",
|
|
8: "xl:col-span-8",
|
|
};
|
|
|
|
// 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.
|
|
// ── Warum die Kacheln in Gruppen stehen ────────────────────────────
|
|
//
|
|
// Acht Zahlen nebeneinander sind acht Zahlen. Sie beantworten aber drei
|
|
// verschiedene Fragen: wie viele sind da, was hat sich bewegt, was ist
|
|
// offen. Ohne Überschrift muss man jede Beschriftung einzeln lesen, um das
|
|
// herauszufinden — mit ihr sieht man es, bevor man liest.
|
|
//
|
|
// Die Einteilung und die Reihenfolge kommen aus einem Entwurf des Kunden
|
|
// (E-Mail vom 16.09.2026). „Aktives Dienstverhältnis" steht darin bewusst
|
|
// **vorn**: es ist die Bezugsgrösse fast jeder Personalkennzahl, und wer
|
|
// eine Quote bildet, greift zuerst danach.
|
|
const kpiGruppen: { titel: string; kacheln: Kachel[] }[] = [
|
|
{
|
|
titel: "Personalstand",
|
|
kacheln: [
|
|
// Nicht dasselbe wie „Aktive Mitarbeiter:innen" daneben: dort steht,
|
|
// wer heute arbeitet, hier, mit wem ein Vertrag läuft —
|
|
// Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10,
|
|
// addieren musste man selbst.
|
|
{
|
|
label: "Aktives Dienstverhältnis",
|
|
value: activeCount + karenzCount,
|
|
tone: "default",
|
|
// Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter
|
|
// als *eine* Zeichenkette und trennt selbst. Zweimal übergeben macht
|
|
// Next daraus ein Array, und die Seite scheiterte an `.split(",")`.
|
|
href: "/employees?status=Aktiv,Karenz",
|
|
},
|
|
// „(HC)" für Headcount — der Unterschied zur Kachel links ist genau
|
|
// der, den die Abkürzung benennt: Köpfe, die heute arbeiten.
|
|
{ label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
|
|
{ label: "Langzeitabwesende", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
|
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
|
|
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
|
|
// Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht.
|
|
{
|
|
label: "Geplante Eintritte",
|
|
value: staffRows.filter((row) => statusOf(row) === "Geplant").length,
|
|
tone: "info",
|
|
href: "/employees?status=Geplant",
|
|
},
|
|
{ label: "Aktive FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
|
],
|
|
},
|
|
{
|
|
titel: "Personalbewegung",
|
|
kacheln: [
|
|
{
|
|
// YTD: seit Jahresbeginn bis heute, nicht das ganze Kalenderjahr.
|
|
label: "Eintritte (YTD)",
|
|
value: hiresYtd,
|
|
tone: "success",
|
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
|
},
|
|
{
|
|
label: "Austritte (YTD)",
|
|
value: exitsYtd,
|
|
tone: "danger",
|
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
titel: "Recruiting & Vakanzen",
|
|
kacheln: [{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }],
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
|
{/* ── Ein Raster, nicht drei nebeneinandergestellte ──────────────
|
|
Alle acht Kacheln liegen in **einem** Raster mit acht gleichen
|
|
Spalten und einem einzigen Abstandswert. Nur so sind sie gleich
|
|
breit und gleich weit auseinander — unabhängig davon, zu welcher
|
|
Gruppe sie gehören.
|
|
|
|
Die beiden Anläufe davor stellten drei eigene Raster nebeneinander.
|
|
Das ging zweimal schief: erst bekam jede Gruppe ein Drittel der
|
|
Breite (fünf Kacheln gequetscht, eine mit demselben Platz), dann
|
|
einen Anteil nach Kachelzahl — da stimmten die Breiten fast, aber
|
|
eben nur fast: eine Gruppe mit fünf Kacheln hat vier Abstände in
|
|
sich, eine mit einer keinen, und dieser Unterschied verteilt sich
|
|
auf die Kachelbreiten. Gleichmässig wird es erst, wenn alle Kacheln
|
|
demselben Raster angehören.
|
|
|
|
Die Überschriften sitzen in Zeile 1 und überspannen die Spalten
|
|
ihrer Gruppe, die Kacheln in Zeile 2. Beide Zeilen entstehen aus
|
|
derselben DOM-Reihenfolge (Überschrift, ihre Kacheln, nächste
|
|
Überschrift …) — die ist für Vorlesegeräte die richtige, und die
|
|
ausdrückliche Zeilenangabe sortiert sie fürs Auge.
|
|
|
|
Unterhalb der grossen Breite gibt es keine Zeilenangabe: dann fliesst
|
|
alles der Reihe nach, die Überschrift über die volle Breite und ihre
|
|
Kacheln zu zweit darunter. Acht nebeneinander wären auf einem Laptop
|
|
unlesbar schmal. */}
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-3 xl:grid-cols-8 xl:gap-y-1.5">
|
|
{kpiGruppen.map((gruppe) => (
|
|
<Fragment key={gruppe.titel}>
|
|
<h2
|
|
className={`mt-2 self-end text-[11px] font-bold uppercase tracking-wider text-ink-muted
|
|
col-span-2 xl:mt-0 xl:[grid-row:1] ${SPALTEN_SPAN[gruppe.kacheln.length]}`}
|
|
>
|
|
{gruppe.titel}
|
|
</h2>
|
|
{gruppe.kacheln.map((kpi) => (
|
|
<Link
|
|
key={kpi.label}
|
|
href={kpi.href}
|
|
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow xl:[grid-row:2] 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.
|
|
|
|
Kleiner, sobald alle acht in einer Reihe stehen: dort
|
|
bleiben je Kachel gut 110 px, und „748.4" braucht in 30 px
|
|
Schriftgrad mehr davon, als nach dem Innenabstand übrig
|
|
ist. Die Kachel hat `overflow-hidden` — die Zahl wäre also
|
|
nicht zu breit, sondern abgeschnitten, und eine
|
|
abgeschnittene Kennzahl ist schlimmer als eine kleine. */}
|
|
<div className={`text-3xl font-extrabold leading-none tabular-nums xl:text-2xl ${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>
|
|
))}
|
|
</Fragment>
|
|
))}
|
|
</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">
|
|
{/* Der Name führt in die Akte. Ohne den Link war die Karte
|
|
eine Sackgasse: man sah, dass etwas passiert ist, und
|
|
musste die Person danach in der Liste suchen. */}
|
|
{h.first_name && h.last_name ? (
|
|
<Link
|
|
href={`/employees/${h.employee_id}`}
|
|
className="rounded text-sm font-semibold text-ink hover:text-brand-700 hover:underline
|
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
>
|
|
{fmtName(h.first_name, h.last_name)}
|
|
</Link>
|
|
) : (
|
|
<span className="text-sm font-semibold text-ink">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>
|
|
);
|
|
}
|