Anmerkungen vom 16.09.: Uebersicht gegliedert, Personalnummer prueft frueher

1) Die Kacheln stehen jetzt in drei Gruppen — Personalstand,
Personalbewegung, Recruiting & Vakanzen — in der Reihenfolge aus dem Entwurf
des Kunden. Acht Zahlen nebeneinander sind acht Zahlen; sie beantworten aber
drei verschiedene Fragen, und ohne Ueberschrift muss man jede Beschriftung
einzeln lesen, um das herauszufinden. "Aktives Dienstverhaeltnis" steht
vorn: es ist die Bezugsgroesse fast jeder Personalkennzahl.

2) Der Namensfilter in "Anstehend" ist jetzt immer da. Die Schwelle "erst ab
neun Eintraegen" war in der Bedienung falsch — das Feld erschien bei 180
Tagen und verschwand bei 30, und ein Bedienelement, das je nach Zeitraum da
ist oder nicht, wirkt wie ein Fehler.

3b) Der Filter heisst jetzt "Aktives Dienstverhaeltnis (Aktiv +
Langzeitabwesenheit)" — derselbe Name wie die Kachel, die dorthin verlinkt.

6) Die Personalnummer wird gegen die Datenbank geprueft, waehrend sie
eingetippt wird, und nennt bei einem Treffer die Person, die sie schon hat.
hire_employee weist sie weiterhin ab — das bleibt die verbindliche Pruefung,
denn zwischen Frage und Anlegen kann jemand anderes dieselbe Nummer
vergeben. Nur kam diese Abweisung bisher nach sechs Schritten Eingabe, und
das Feld steht im ersten Schritt.

Gemerkt wird dabei die gepruefte *Nummer* samt Ergebnis, nicht ein Ja/Nein:
so ist die Sperre eine Ableitung aus dem, was im Feld steht, und es gibt
keinen Zustand, dessen Zuruecksetzen man vergessen koennte.

Zu 4) geprueft, nichts geaendert: die FTE-Kachel rechnet bereits Summe der
Wochenstunden der heute Aktiven durch 38,5. Der Berichtemanager rechnet
dieselbe Formel, nur als Summe der Einzelquotienten geschrieben.
This commit is contained in:
2026-09-16 22:23:04 +02:00
parent 4dc27bf212
commit 9ad2954970
7 changed files with 266 additions and 75 deletions

View File

@@ -28,6 +28,9 @@ const TONE: Record<string, { text: string; bar: string }> = {
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 };
// 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
@@ -151,78 +154,104 @@ export default async function DashboardPage({
// 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 = [
// ── 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[] }[] = [
{
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}`,
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: "Langzeitabwesend", 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" },
],
},
{
label: "Austritte (Jahr)",
value: exitsYtd,
tone: "danger",
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
titel: "Personalbewegung",
kacheln: [
{
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" },
// ── Zwei Kacheln aus dem Workshop (Anforderung 10) ──────────────
//
// „Aktives Dienstverhältnis" ist nicht dasselbe wie „Aktive
// Mitarbeiter:innen" ganz links. Dort steht, wer heute arbeitet; hier,
// mit wem ein Vertrag läuft — Langzeitabwesende eingeschlossen. Genau
// diese Zahl ist die Bezugsgrösse fast jeder Personalkennzahl, und sie
// war bisher nirgends zu sehen: sichtbar waren 806 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",
titel: "Recruiting & Vakanzen",
kacheln: [{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }],
},
// Geplante Eintritte 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 in der
// Vorschau noch nicht auftaucht.
{
label: "Geplante Eintritte",
value: staffRows.filter((row) => statusOf(row) === "Geplant").length,
tone: "info",
href: "/employees?status=Geplant",
},
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
];
return (
<div className="flex flex-col gap-6">
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
{/* Acht Kacheln: vier je Zeile ab der grossen Breite, damit keine
Zeile mit zwei Resten endet. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-4 xl:grid-cols-8">
{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 />
{/* Die Gruppen nebeneinander, solange der Platz reicht, und darunter,
wenn nicht. `flex-wrap` statt eines Rasters: die Gruppen sind
verschieden breit (fünf, zwei, eine Kachel), und ein Raster zwänge
sie in gleiche Spalten — die einzelne Kachel rechts bekäme dann
denselben Platz wie fünf. */}
<div className="flex flex-wrap items-start gap-x-6 gap-y-4">
{kpiGruppen.map((gruppe) => (
<section key={gruppe.titel} className="min-w-0 flex-1">
<h2 className="mb-1.5 text-[11px] font-bold uppercase tracking-wider text-ink-muted">{gruppe.titel}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(auto-fit,minmax(9rem,1fr))]">
{gruppe.kacheln.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>
</Link>
</section>
))}
</div>