Die Exportkarten nennen die ganze Auswahl, nicht nur den Status
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m14s

Das Einschraenken der Downstream-Exporte gab es schon: exportHref reicht die
ganze Adresszeile weiter, und ladeExportMitarbeiter wendet dieselben Kriterien
an wie der Bericht daneben -- Eintritt ab, Austritt bis, Wochenstunden,
Beschaeftigungsart und die uebrigen. Nur sah man es den Karten nicht an. Dort
stand ausschliesslich der Status; alles andere steckt eingeklappt in "Weitere
Kriterien" weiter oben.

Damit sah eine Datei mit 40 Personen genauso aus wie eine mit 785. Fuer einen
Bericht ist das aergerlich, fuer ein Load-File in ein Zielsystem ist es ein
Datenstand, den dort niemand mehr hinterfragt: es fehlen Personen, und es sieht
nicht nach einem Filter aus.

Die drei Karten im Bestandsmodus zeigen jetzt Stichtag, Status, Einheit,
Standort, jedes gesetzte Kriterium und die Zeilenzahl. beschreibeKriterien
steht neben anzahlKriterien in derselben Datei und liest dieselben Listen -- ein
neues Kriterium erscheint damit von selbst auch auf den Karten. Ein Test haelt
beide gegeneinander, damit die Zahl am Aufklapper und der Text auf der Karte
nicht auseinanderlaufen.
This commit is contained in:
2026-09-28 12:25:17 +02:00
parent 677ca35df2
commit af2ad53ef9
3 changed files with 133 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ import { fmtDate } from "@/lib/format";
import { import {
anzahlKriterien, anzahlKriterien,
AUSWAHL_KRITERIEN, AUSWAHL_KRITERIEN,
beschreibeKriterien,
JANEIN_KRITERIEN, JANEIN_KRITERIEN,
kriterienParameter, kriterienParameter,
ZEITRAUM_KRITERIEN, ZEITRAUM_KRITERIEN,
@@ -212,7 +213,8 @@ function KriterienBlock({
</fieldset> </fieldset>
<p className="text-xs text-ink-muted"> <p className="text-xs text-ink-muted">
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte. Alle Kriterien gelten für den Bericht <em>und</em> für jeden Export dieser Seite — auch für den
Honestly- und den Cornerstone-Report. Was gerade eingestellt ist, steht auf der jeweiligen Karte.
</p> </p>
</div> </div>
)} )}
@@ -220,6 +222,27 @@ function KriterienBlock({
); );
} }
/**
* Was gerade in die Datei geht — Stichtag, Status, Einheit, Standort und jedes
* gesetzte Kriterium, dazu die Zeilenzahl.
*
* Auf den Karten stand nur der Status. Alles andere schränkt den Export
* genauso ein, steht aber eingeklappt weiter oben: eine Datei mit 40 statt 785
* Personen sah genauso aus wie eine vollständige. Für einen Bericht ist das
* ärgerlich, für ein Load-File ins Zielsystem ist es ein Datenstand, den dort
* niemand mehr hinterfragt.
*/
function AuswahlHinweis({ texte, anzahl }: { texte: string[]; anzahl: number }) {
return (
<div className="mb-2 rounded bg-surface px-2 py-1.5 text-xs text-ink-body">
<span className="font-semibold">Auswahl:</span> {texte.join(" · ")}
<span className="mt-0.5 block font-semibold">
{anzahl} {anzahl === 1 ? "Person" : "Personen"}
</span>
</div>
);
}
function formatValue(measure: Measure, value: number): string { function formatValue(measure: Measure, value: number): string {
if (measure === "headcount") return String(Math.round(value)); if (measure === "headcount") return String(Math.round(value));
if (measure === "fte") return value.toFixed(1); if (measure === "fte") return value.toFixed(1);
@@ -355,6 +378,20 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday"); const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday");
const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : []; const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : [];
const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", "); const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", ");
// Einheit und Standort stehen in der Adresszeile als Kennung; auf der Karte
// hilft nur der Name.
const einheitName = mode === "snapshot" ? units.find((u) => u.id === props.filters.division)?.name : undefined;
const standortName = mode === "snapshot" ? locations.find((l) => l.id === props.filters.location)?.name : undefined;
const auswahlTexte =
mode === "snapshot"
? [
`Stichtag ${fmtDate(props.asOf || todayIso())}`,
`Status ${statusExportLabel}`,
...(einheitName ? [`Einheit ${einheitName}`] : []),
...(standortName ? [`Standort ${standortName}`] : []),
...beschreibeKriterien(props.criteria),
]
: [];
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const defaultEventFrom = `${currentYear}-01-01`; const defaultEventFrom = `${currentYear}-01-01`;
const defaultEventTo = `${currentYear}-12-31`; const defaultEventTo = `${currentYear}-12-31`;
@@ -619,8 +656,8 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vollständiger Datenexport</h3> <h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vollständiger Datenexport</h3>
{mode === "snapshot" ? ( {mode === "snapshot" ? (
<> <>
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten (nicht nur die Kennzahl){props.asOf ? ` zum Stichtag ${fmtDate(props.asOf)}` : ""}.</p> <p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten, nicht nur die Kennzahl.</p>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p> <AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
</> </>
) : ( ) : (
<p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p> <p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p>
@@ -643,7 +680,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
{mode === "snapshot" && ( {mode === "snapshot" && (
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3> <h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p> <AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
<div className="flex gap-2"> <div className="flex gap-2">
<a href={exportHref("/api/export/honestly", "csv")} className={LINK_BUTTON_CLASS}> <a href={exportHref("/api/export/honestly", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@@ -665,7 +702,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und
Position ID — dafür fehlen die Kennungen des Zielsystems. Position ID — dafür fehlen die Kennungen des Zielsystems.
</p> </p>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p> <AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
<div className="flex gap-2"> <div className="flex gap-2">
<a href={exportHref("/api/export/cornerstone", "csv")} className={LINK_BUTTON_CLASS}> <a href={exportHref("/api/export/cornerstone", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />

View File

@@ -1,5 +1,6 @@
import { ABSENCE_TYPES } from "./absence"; import { ABSENCE_TYPES } from "./absence";
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNGSART_WERTE } from "./beendigung"; import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNGSART_WERTE } from "./beendigung";
import { fmtDate } from "./format";
import { HAY_GRADE_WERTE } from "./hay-grade"; import { HAY_GRADE_WERTE } from "./hay-grade";
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz"; import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
import { MITARBEITERARTEN } from "./mitarbeiterart"; import { MITARBEITERARTEN } from "./mitarbeiterart";
@@ -179,6 +180,49 @@ export function anzahlKriterien(c: Criteria): number {
return n; return n;
} }
/**
* Die gesetzten Kriterien in Worten.
*
* Die Exportkarten nannten nur den Status. Die übrigen Kriterien stehen
* eingeklappt weiter oben und wirken auf jeden Export dieser Seite genauso —
* wer eines gesetzt und vergessen hatte, lud eine Datei herunter, die nach dem
* ganzen Haus aussah und einen Ausschnitt enthielt. Bei einer Datei für ein
* Zielsystem merkt das niemand mehr, sobald sie dort eingespielt ist: dort
* fehlen dann Personen, und es sieht aus wie ein Datenstand, nicht wie ein
* Filter.
*
* `anzahlKriterien` daneben zählt dasselbe; hier steht, *was* es ist.
*/
export function beschreibeKriterien(c: Criteria): string[] {
const teile: string[] = [];
for (const k of AUSWAHL_KRITERIEN) {
const wert = c.auswahl[k.key];
if (!wert) continue;
teile.push(`${k.label}: ${k.optionen.find((o) => o.wert === wert)?.label ?? wert}`);
}
for (const k of JANEIN_KRITERIEN) {
const wert = c.jaNein[k.key];
if (wert) teile.push(`${k.label}: ${wert}`);
}
for (const k of ZEITRAUM_KRITERIEN) {
const z = c.zeitraum[k.key];
if (!z) continue;
if (z.von && z.bis) teile.push(`${k.label} ${fmtDate(z.von)} bis ${fmtDate(z.bis)}`);
else if (z.von) teile.push(`${k.label} ab ${fmtDate(z.von)}`);
else if (z.bis) teile.push(`${k.label} bis ${fmtDate(z.bis)}`);
}
const { stundenVon: von, stundenBis: bis } = c;
if (von !== undefined && bis !== undefined) teile.push(`Wochenstunden ${von} bis ${bis}`);
else if (von !== undefined) teile.push(`Wochenstunden ab ${von}`);
else if (bis !== undefined) teile.push(`Wochenstunden bis ${bis}`);
return teile;
}
/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */ /** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */
export function kriterienParameter(): string[] { export function kriterienParameter(): string[] {
return [ return [

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
anzahlKriterien, anzahlKriterien,
AUSWAHL_KRITERIEN, AUSWAHL_KRITERIEN,
beschreibeKriterien,
JANEIN_KRITERIEN, JANEIN_KRITERIEN,
kriterienParameter, kriterienParameter,
LEERE_CRITERIA, LEERE_CRITERIA,
@@ -58,6 +59,52 @@ describe("parseCriteria", () => {
}); });
}); });
describe("beschreibeKriterien", () => {
it("sagt nichts, wenn nichts eingeschränkt ist", () => {
expect(beschreibeKriterien(LEERE_CRITERIA)).toEqual([]);
});
it("nennt jedes gesetzte Kriterium beim Namen", () => {
const c = aus({ employment: "Teilzeit", dienstwagen: "ja", entryFrom: "2024-01-01", hoursTo: "30" });
expect(beschreibeKriterien(c)).toEqual([
"Beschäftigungsart: Teilzeit",
"Dienstwagen: ja",
"Eintritt ab 01.01.2024",
"Wochenstunden bis 30",
]);
});
it("unterscheidet ab, bis und den geschlossenen Zeitraum", () => {
expect(beschreibeKriterien(aus({ entryFrom: "2020-06-01" }))).toEqual(["Eintritt ab 01.06.2020"]);
expect(beschreibeKriterien(aus({ entryTo: "2020-06-01" }))).toEqual(["Eintritt bis 01.06.2020"]);
expect(beschreibeKriterien(aus({ entryFrom: "2020-01-01", entryTo: "2020-12-31" }))).toEqual([
"Eintritt 01.01.2020 bis 31.12.2020",
]);
});
it("zeigt die Beschriftung, nicht den gespeicherten Wert", () => {
// Bei der Austrittsart gehen die beiden auseinander; auf der Karte nützt
// nur das, was auch im Auswahlfeld steht.
const kriterium = AUSWAHL_KRITERIEN.find((k) => k.key === "austrittsart")!;
const option = kriterium.optionen[0];
expect(beschreibeKriterien(aus({ austrittsart: option.wert }))).toEqual([`${kriterium.label}: ${option.label}`]);
});
it("beschreibt so viele Kriterien, wie anzahlKriterien zählt", () => {
// Die Zahl am Aufklapper und der Text auf der Exportkarte dürfen nicht
// auseinanderlaufen: sonst meldet die eine Stelle eine Einschränkung, die
// die andere nicht benennt.
const alle = {
...Object.fromEntries(AUSWAHL_KRITERIEN.map((k) => [k.key, k.optionen[0].wert])),
...Object.fromEntries(JANEIN_KRITERIEN.map((k) => [k.key, "ja"])),
...Object.fromEntries(ZEITRAUM_KRITERIEN.map((k) => [`${k.key}From`, "2024-01-01"])),
hoursFrom: "10",
};
const c = aus(alle);
expect(beschreibeKriterien(c)).toHaveLength(anzahlKriterien(c));
});
});
describe("kriterienParameter", () => { describe("kriterienParameter", () => {
it("nennt jeden Parameter, den parseCriteria liest", () => { it("nennt jeden Parameter, den parseCriteria liest", () => {
// „Zurücksetzen" räumt anhand dieser Liste auf. Fehlt darin ein Name, // „Zurücksetzen" räumt anhand dieser Liste auf. Fehlt darin ein Name,