From 0b8f874fa559dab3da79f1994917ed8150efe538 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Wed, 12 Aug 2026 12:40:47 +0200 Subject: [PATCH] Let the export select on everything the data model holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export offered four criteria — unit, location, status, employment type — while the employee record carries around twenty selectable attributes. Anything else had to be filtered by hand in Excel afterwards, which is how a payroll hand-off stops matching the application it came from. All of them are now filters: contract type, blue/white collar, collective agreement, paygrade, internal/external, gender, company car and its drivetrain, works council, lateral leadership, C-level, type of long-term absence, weekday worked, dependents on file, and open ranges for entry, exit, birth date and weekly hours. The unit filter covers every level rather than only divisions, so a single department can be selected without going the long way round. They live in one table in lib/report-criteria.ts, which the filter panel builds itself from, the parser validates against, and the query turns into conditions. A new criterion is one entry there and nothing else — and it cannot end up working in the report while being silently ignored by the export. The two export links and the saved-report config now carry the query string through as it stands instead of listing the parameters they know about. That enumeration was the actual defect: adding a filter meant remembering three separate places, and forgetting one produced an export that quietly disagreed with the figure on screen. Validation is not housekeeping here. These values reach SQL comparisons and the download filename, i.e. a Content-Disposition header; what is not in the list does not get through. The company car dropdown leaves the employee list. It is one of twenty equals under Berichte now, where the selection can also be exported — which was the point of asking in the first place. Co-Authored-By: Claude Opus 5 --- app/(app)/employees/page.tsx | 10 - app/(app)/reports/page.tsx | 22 +- app/api/export/employees/route.ts | 14 +- app/api/export/report/route.ts | 3 +- components/employees/EmployeeFilters.tsx | 22 +- components/reports/ReportsPageClient.tsx | 246 ++++++++++++++++++----- lib/report-criteria.ts | 169 ++++++++++++++++ lib/reports-data.ts | 97 ++++++++- lib/reports.ts | 6 + tests/unit/report-criteria.test.ts | 99 +++++++++ 10 files changed, 586 insertions(+), 102 deletions(-) create mode 100644 lib/report-criteria.ts create mode 100644 tests/unit/report-criteria.test.ts diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 8ad8438..908c53c 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -20,8 +20,6 @@ type SearchParams = { division?: string; status?: string; location?: string; - /** "ja" | "nein" | "elektro" | "verbrenner" — alles andere wird übergangen. */ - dienstwagen?: string; page?: string; }; @@ -35,7 +33,6 @@ function pageHref(params: SearchParams, page: number): string { if (params.division) sp.set("division", params.division); if (params.status) sp.set("status", params.status); if (params.location) sp.set("location", params.location); - if (params.dienstwagen) sp.set("dienstwagen", params.dienstwagen); sp.set("page", String(page)); return `/employees?${sp.toString()}`; } @@ -135,13 +132,6 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps } if (params.location) q = q.where("location_id", "=", params.location); - - // Auf die vier bekannten Werte eingeschränkt: der Parameter kommt aus - // der Adresszeile und ist damit fremdbestimmt. - if (params.dienstwagen === "ja") q = q.where("has_dienstwagen", "=", true); - else if (params.dienstwagen === "nein") q = q.where("has_dienstwagen", "=", false); - else if (params.dienstwagen === "elektro") q = q.where("dienstwagen_art", "=", "Elektro"); - else if (params.dienstwagen === "verbrenner") q = q.where("dienstwagen_art", "=", "Verbrenner"); return q; }; diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index 5cc1949..2543fba 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -15,11 +15,15 @@ import { sumValues, totalForRows, } from "@/lib/reports"; +import { parseCriteria } from "@/lib/report-criteria"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; -type SearchParams = { +// Nur die Parameter, die diese Seite selbst auswertet. Die Auswahlkriterien +// stehen ebenfalls in der Adresszeile, werden aber geschlossen von +// parseCriteria gelesen — siehe lib/report-criteria.ts. +type SearchParams = Record & { mode?: string; measure?: string; group?: string; @@ -27,7 +31,6 @@ type SearchParams = { division?: string; location?: string; status?: string; - employment?: string; asOf?: string; eventType?: string; from?: string; @@ -50,6 +53,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom const measure = parseMeasure(params.measure); const group = parseGroupDimension(params.group); const split = parseSplitDimension(params.split); + const criteria = parseCriteria((k) => (typeof params[k] === "string" ? (params[k] as string) : undefined)); // The report data depends on neither the org lookups nor on who is signed // in, so all three go out together. Against a hosted database a round trip @@ -61,8 +65,8 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom // Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei // Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste // Teil dieser Seite war. - const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => { - const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([ + const { lookups, units, locations, events, employees, savedReports } = await withUser(userId, async (tx) => { + const [{ lookups, units, locations }, events, employees, savedReports] = await Promise.all([ loadOrgLookups(tx), mode === "events" ? loadEventHistory(tx, { @@ -78,7 +82,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom division: params.division, location: params.location, status: params.status, - employment: params.employment, + criteria, asOf, }) : Promise.resolve([]), @@ -91,7 +95,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom .execute() : Promise.resolve([]), ]); - return { lookups, divisions, locations, events, employees, savedReports }; + return { lookups, units, locations, events, employees, savedReports }; }); if (mode === "events") { @@ -108,7 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom rows={rows} total={sumValues(rows)} recordCount={events.length} - divisions={divisions} + units={units} locations={locations} savedReports={savedReports} /> @@ -130,12 +134,12 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom division: params.division ?? "", location: params.location ?? "", status: params.status ?? "", - employment: params.employment ?? "", }} + criteria={criteria} rows={rows} total={totalForRows(rows, measure)} recordCount={employees.length} - divisions={divisions} + units={units} locations={locations} savedReports={savedReports} /> diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index f9d38cd..8c32f88 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -4,11 +4,12 @@ import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn import { todayIso } from "@/lib/format"; import { subtreeOf } from "@/lib/org"; import { loadPlacements, loadReportingLineMap } from "@/lib/placement"; +import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; -import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; +import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { requireHrUser } from "@/lib/auth/require-hr"; import { withUser } from "@/lib/db"; -import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types"; +import type { Database, Weekday } from "@/lib/supabase/types"; // Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt // über die Planstelle und die abgeleitete Berichtslinie. @@ -35,7 +36,7 @@ export async function GET(request: NextRequest) { division: params.get("division") ?? undefined, location: params.get("location") ?? undefined, status: params.get("status") ?? undefined, - employment: params.get("employment") ?? undefined, + criteria: parseCriteria((k) => params.get(k)), }; const statuses = parseStatuses(filters.status); @@ -48,9 +49,10 @@ export async function GET(request: NextRequest) { function employeeQuery() { let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id"); if (filters.location) q = q.where("location_id", "=", filters.location); - if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType); if (!asOf) q = q.where("status", "in", statuses); - return q; + // Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die + // Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein. + return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA); } const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ @@ -79,10 +81,12 @@ export async function GET(request: NextRequest) { // Export für "Produktion" nur die Bereichsleitung. const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; + const criteria = filters.criteria ?? LEERE_CRITERIA; const enriched: EmployeeRow[] = employees.flatMap((e) => { const placement = placements.get(e.id); const orgUnitId = placement?.current ? placement.orgUnitId : null; if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return []; + if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return []; return [{ ...e, org_unit_id: orgUnitId, diff --git a/app/api/export/report/route.ts b/app/api/export/report/route.ts index 233e5b0..8766009 100644 --- a/app/api/export/report/route.ts +++ b/app/api/export/report/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; +import { parseCriteria } from "@/lib/report-criteria"; import { aggregateEvents, aggregateReport, @@ -69,7 +70,7 @@ export async function GET(request: NextRequest) { division: params.get("division") ?? undefined, location: params.get("location") ?? undefined, status: params.get("status") ?? undefined, - employment: params.get("employment") ?? undefined, + criteria: parseCriteria((k) => params.get(k)), asOf, }); rows = aggregateReport(employees, measure, group, split, lookups, asOf); diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 49b4077..72757ad 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -104,22 +104,12 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr ))} - {/* „Wer hat einen Dienstwagen" ist eine wiederkehrende Frage — als - Filter beantwortet sie sich mit einem Klick, statt über einen - Export mit Nachbearbeitung. E-KFZ getrennt, weil das die Frage - hinter der Frage ist. */} - + {/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist + jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit + Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest — + dort lässt sich die Auswahl auch exportieren, was der eigentliche + Zweck der Frage war. In dieser Leiste, die vor allem zum Suchen da + ist, wäre er ein Sonderfall unter vielen gleichrangigen. */} ); } diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index 4714480..d94e4cc 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react"; +import { ChevronDown, FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; @@ -10,6 +10,14 @@ import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; +import { + anzahlKriterien, + AUSWAHL_KRITERIEN, + JANEIN_KRITERIEN, + kriterienParameter, + ZEITRAUM_KRITERIEN, + type Criteria, +} from "@/lib/report-criteria"; import { AVERAGE_MEASURES, EVENT_DATE_OPEN, @@ -27,6 +35,7 @@ import { type GroupDimension, type Measure, type ReportRow, + type UnitOption, } from "@/lib/reports"; import type { HistoryEventType } from "@/lib/supabase/types"; @@ -40,7 +49,7 @@ type CommonProps = { rows: ReportRow[]; total: number; recordCount: number; - divisions: OrgOption[]; + units: UnitOption[]; locations: OrgOption[]; savedReports: SavedReport[]; }; @@ -51,7 +60,8 @@ type SnapshotProps = CommonProps & { group: GroupDimension; split: GroupDimension | ""; asOf: string; - filters: { division: string; location: string; status: string; employment: string }; + filters: { division: string; location: string; status: string }; + criteria: Criteria; }; type EventsProps = CommonProps & { @@ -64,6 +74,144 @@ type EventsProps = CommonProps & { type ReportsPageClientProps = SnapshotProps | EventsProps; +/** + * Alle übrigen Auswahlkriterien, eingeklappt. + * + * Aufgeklappt wäre die Leiste dreimal so lang wie der Bericht daneben, und + * die vier Filter, die fast immer gemeint sind, würden darin untergehen. + * Eingeklappt steht nur die Zahl der gesetzten Kriterien da — und die ist + * das, was man wissen muss: ob gerade etwas einschränkt, das man vergessen + * hat. + */ +function KriterienBlock({ + criteria, + onChange, + onReset, +}: { + criteria: Criteria; + onChange: (patch: Record) => void; + onReset: () => void; +}) { + const [offen, setOffen] = useState(false); + const anzahl = anzahlKriterien(criteria); + + return ( +
+
+ + {anzahl > 0 && ( + + )} +
+ + {offen && ( +
+ {AUSWAHL_KRITERIEN.map((k) => ( + + ))} + +
+ Merkmale +
+ {JANEIN_KRITERIEN.map((k) => ( +
+ {k.label} + +
+ ))} +
+
+ + {ZEITRAUM_KRITERIEN.map((k) => ( +
+ {k.label} +
+ onChange({ [`${k.key}From`]: v || undefined })} + /> + onChange({ [`${k.key}To`]: v || undefined })} + /> +
+
+ ))} + +
+ Wochenstunden +
+ onChange({ hoursFrom: v || undefined })} + /> + onChange({ hoursTo: v || undefined })} + /> +
+
+ +

+ Alle Kriterien gelten für den Bericht und für beide Exporte. +

+
+ )} +
+ ); +} + function formatValue(measure: Measure, value: number): string { if (measure === "headcount") return String(Math.round(value)); if (measure === "fte") return value.toFixed(1); @@ -73,7 +221,7 @@ function formatValue(measure: Measure, value: number): string { } export function ReportsPageClient(props: ReportsPageClientProps) { - const { mode, rows, total, recordCount, divisions, locations, savedReports } = props; + const { mode, rows, total, recordCount, units, locations, savedReports } = props; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -103,6 +251,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) { updateParams({ status: next.length > 0 ? next.join(",") : undefined }); } + function resetKriterien() { + const patch: Record = {}; + for (const p of kriterienParameter()) patch[p] = undefined; + updateParams(patch); + } + function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) { const sp = new URLSearchParams({ mode }); if (preset.measure) sp.set("measure", preset.measure); @@ -126,10 +280,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) { showToast("Bitte einen Namen angeben.", "error"); return; } - const config = - mode === "snapshot" - ? { mode, measure: props.measure, group: props.group, split: props.split, asOf: props.asOf, ...props.filters } - : { mode, group: props.eventGroup, split: props.eventSplit, eventType: props.eventType, ...props.eventFilters }; + // Auch hier die ganze Adresszeile: ein gespeicherter Bericht, der die + // Hälfte der Kriterien vergisst, führt beim nächsten Öffnen still zu + // anderen Zahlen. + const config: Record = Object.fromEntries(searchParams.entries()); + config.mode = mode; + config.group = mode === "snapshot" ? props.group : props.eventGroup; + if (mode === "snapshot") config.measure = props.measure; setSavingReport(true); const result = await saveReport({ name: newReportName.trim(), config }); setSavingReport(false); @@ -153,41 +310,30 @@ export function ReportsPageClient(props: ReportsPageClientProps) { } } + // Beide Ausgänge übernehmen die Adresszeile unverändert, statt die + // Parameter einzeln aufzuzählen. Genau diese Aufzählung war der Grund, + // warum ein neues Kriterium im Bericht wirkte und im Export fehlte — man + // musste daran denken, sie an drei Stellen zu ergänzen. Jetzt sieht der + // Export das, was der Bericht gesehen hat, weil es dieselbe Auswahl ist. + function exportHref(ziel: string, format: "csv" | "xlsx"): string { + const sp = new URLSearchParams(searchParams.toString()); + sp.set("format", format); + return `${ziel}?${sp.toString()}`; + } + function reportExportHref(format: "csv" | "xlsx"): string { - const sp = new URLSearchParams(); + const sp = new URLSearchParams(searchParams.toString()); sp.set("format", format); sp.set("mode", mode); - if (mode === "snapshot") { - sp.set("measure", props.measure); - sp.set("group", props.group); - if (props.split) sp.set("split", props.split); - if (props.asOf) sp.set("asOf", props.asOf); - for (const [k, v] of Object.entries(props.filters)) if (v) sp.set(k, v); - } else { - sp.set("group", props.eventGroup); - if (props.eventSplit) sp.set("split", props.eventSplit); - if (props.eventType) sp.set("eventType", props.eventType); - for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v); - } + // Die Vorgaben stehen nicht zwingend in der Adresszeile; die Route + // braucht sie aber, um dieselbe Auswertung zu bauen. + sp.set("group", mode === "snapshot" ? props.group : props.eventGroup); + if (mode === "snapshot") sp.set("measure", props.measure); return `/api/export/report?${sp.toString()}`; } function fullExportHref(format: "csv" | "xlsx"): string { - if (mode === "snapshot") { - const sp = new URLSearchParams(); - sp.set("format", format); - if (props.asOf) sp.set("asOf", props.asOf); - if (props.filters.division) sp.set("division", props.filters.division); - if (props.filters.location) sp.set("location", props.filters.location); - if (props.filters.status) sp.set("status", props.filters.status); - if (props.filters.employment) sp.set("employment", props.filters.employment); - return `/api/export/employees?${sp.toString()}`; - } - const sp = new URLSearchParams(); - sp.set("format", format); - if (props.eventType) sp.set("eventType", props.eventType); - for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v); - return `/api/export/events?${sp.toString()}`; + return exportHref(mode === "snapshot" ? "/api/export/employees" : "/api/export/events", format); } const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure); @@ -367,15 +513,19 @@ export function ReportsPageClient(props: ReportsPageClientProps) {

Filter

@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) { ))}
- )} + + {mode === "snapshot" && ( + + )}
diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts new file mode 100644 index 0000000..c94ea7c --- /dev/null +++ b/lib/report-criteria.ts @@ -0,0 +1,169 @@ +import { ABSENCE_TYPES } from "./absence"; +import { parseIsoDateParam } from "./reports"; +import type { Weekday } from "./supabase/types"; + +// Ein Verzeichnis aller Auswahlkriterien — für die Oberfläche, die Abfrage +// und den Export dasselbe. +// +// Ein Export, der weniger filtern kann als der Bericht daneben, ist der +// Grund, warum Leute Daten nach Excel kippen und dort weiterarbeiten: was +// die Anwendung nicht hergibt, wird eben von Hand nachgebaut, und ab da +// stimmt es mit nichts mehr überein. Deshalb steht hier jedes Kriterium +// einmal, mit seinen erlaubten Werten, und alle drei Seiten lesen aus +// derselben Liste: +// +// * die Oberfläche baut ihre Felder daraus, +// * `parseCriteria` prüft die Adresszeile dagegen, +// * `applyCriteria` (serverseitig) macht Bedingungen daraus. +// +// Ein neues Kriterium ist damit ein Eintrag in dieser Datei und sonst +// nichts — und es kann nicht passieren, dass es im Bericht wirkt, im Export +// aber stillschweigend ignoriert wird. +// +// Die Prüfung ist nicht bloss Ordnungsliebe: die Werte landen in +// SQL-Vergleichen und im Dateinamen des Downloads, also in einem +// Content-Disposition-Header. Was nicht in der Liste steht, kommt nicht +// durch. + +export type JaNein = "ja" | "nein"; + +/** Ein Kriterium mit fester Werteliste. */ +export type AuswahlKriterium = { + key: string; + label: string; + /** Beschriftung für „keine Einschränkung". */ + alle: string; + optionen: readonly { wert: string; label: string }[]; +}; + +function werte(...w: string[]): { wert: string; label: string }[] { + return w.map((x) => ({ wert: x, label: x })); +} + +export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [ + { key: "employment", label: "Beschäftigungsart", alle: "Alle Beschäftigungsarten", optionen: werte("Vollzeit", "Teilzeit") }, + { key: "contract", label: "Vertragsart", alle: "Alle Vertragsarten", optionen: werte("unbefristet", "befristet") }, + { key: "worker", label: "Angestellte:r / Arbeiter:in", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in") }, + { key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") }, + { key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") }, + { key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") }, + { + key: "gender", + label: "Geschlecht", + alle: "Alle", + optionen: [ + { wert: "m", label: "männlich" }, + { wert: "w", label: "weiblich" }, + ], + }, + { key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") }, + { key: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) }, + { key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") }, +] as const; + +/** Kriterien über ein Ja/Nein-Feld. */ +export type JaNeinKriterium = { key: string; label: string }; + +export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [ + { key: "dienstwagen", label: "Dienstwagen" }, + { key: "betriebsrat", label: "Betriebsrat" }, + { key: "lateral", label: "Laterale Führung" }, + { key: "clevel", label: "C-Level" }, + { key: "dependents", label: "Angehörige erfasst" }, +] as const; + +/** Zeiträume: je ein Parameter `From` und `To`. */ +export type ZeitraumKriterium = { key: string; label: string }; + +export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [ + { key: "entry", label: "Eintritt" }, + { key: "exit", label: "Austritt" }, + { key: "birth", label: "Geburtsdatum" }, +] as const; + +export type Criteria = { + /** Werteliste je Kriteriumsschlüssel aus AUSWAHL_KRITERIEN. */ + auswahl: Record; + /** Ja/Nein je Schlüssel aus JANEIN_KRITERIEN. */ + jaNein: Record; + /** ISO-Daten je Schlüssel aus ZEITRAUM_KRITERIEN, jeweils von/bis. */ + zeitraum: Record; + /** Wochenstunden von/bis. */ + stundenVon?: number; + stundenBis?: number; +}; + +export const LEERE_CRITERIA: Criteria = { auswahl: {}, jaNein: {}, zeitraum: {} }; + +function parseZahl(value: string | null | undefined): number | undefined { + if (!value) return undefined; + const n = Number(value.replace(",", ".")); + // Negative Stunden oder eine Woche mit 200 Stunden sind keine Eingabe, + // sondern ein Tippfehler oder ein Versuch. + return Number.isFinite(n) && n >= 0 && n <= 168 ? n : undefined; +} + +/** + * Liest die Kriterien aus der Adresszeile und verwirft alles, was nicht in + * den Listen oben steht. + */ +export function parseCriteria(get: (key: string) => string | null | undefined): Criteria { + const auswahl: Record = {}; + for (const k of AUSWAHL_KRITERIEN) { + const roh = get(k.key); + if (roh && k.optionen.some((o) => o.wert === roh)) auswahl[k.key] = roh; + } + + const jaNein: Record = {}; + for (const k of JANEIN_KRITERIEN) { + const roh = get(k.key); + if (roh === "ja" || roh === "nein") jaNein[k.key] = roh; + } + + const zeitraum: Record = {}; + for (const k of ZEITRAUM_KRITERIEN) { + const von = parseIsoDateParam(get(`${k.key}From`)); + const bis = parseIsoDateParam(get(`${k.key}To`)); + if (von || bis) zeitraum[k.key] = { von, bis }; + } + + return { auswahl, jaNein, zeitraum, stundenVon: parseZahl(get("hoursFrom")), stundenBis: parseZahl(get("hoursTo")) }; +} + +/** Wie viele Kriterien gesetzt sind — für die Anzeige „(3)" am Aufklapper. */ +export function anzahlKriterien(c: Criteria): number { + let n = Object.keys(c.auswahl).length + Object.keys(c.jaNein).length; + for (const z of Object.values(c.zeitraum)) n += (z.von ? 1 : 0) + (z.bis ? 1 : 0); + if (c.stundenVon !== undefined) n += 1; + if (c.stundenBis !== undefined) n += 1; + return n; +} + +/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */ +export function kriterienParameter(): string[] { + return [ + ...AUSWAHL_KRITERIEN.map((k) => k.key), + ...JANEIN_KRITERIEN.map((k) => k.key), + ...ZEITRAUM_KRITERIEN.flatMap((k) => [`${k.key}From`, `${k.key}To`]), + "hoursFrom", + "hoursTo", + ]; +} + +/** + * Die zwei Kriterien, die keine Spalte sind: der Wochentag steckt in einem + * Array, die Angehörigen in einer anderen Tabelle. Beide werden an den + * geladenen Zeilen geprüft, statt den Enum-Vergleich bzw. einen Join in die + * Abfrage zu zwingen. + */ +export function passtImSpeicher( + zeile: { work_days: Weekday[] | string[]; dependentsCount: number }, + c: Criteria +): boolean { + const tag = c.auswahl.weekday; + if (tag && !zeile.work_days.includes(tag as Weekday)) return false; + const angehoerige = c.jaNein.dependents; + if (angehoerige === "ja" && zeile.dependentsCount === 0) return false; + if (angehoerige === "nein" && zeile.dependentsCount > 0) return false; + return true; +} diff --git a/lib/reports-data.ts b/lib/reports-data.ts index b948971..66891ff 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -1,8 +1,30 @@ +import type { SelectQueryBuilder } from "kysely"; +import type { Schema } from "./db/schema"; import type { Tx } from "./db"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { loadPlacements } from "./placement"; -import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; -import type { EmploymentType, HistoryEventType } from "./supabase/types"; +import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria"; +import { + deriveStatusAsOf, + EVENT_DATE_OPEN, + parseStatuses, + todayIso, + type OrgLookups, + type ReportEmployee, + type ReportEvent, + type UnitOption, +} from "./reports"; +import type { + CollectiveAgreement, + ContractType, + DienstwagenArt, + EmploymentType, + GenderType, + HistoryEventType, + PaygradeType, + SourceType, + WorkerType, +} from "./supabase/types"; // Shared by the Berichte page and /api/export/* so they can never drift on // what "the current view" means — same filters, same stichtag/event-window @@ -12,9 +34,58 @@ export type ReportFilters = { division?: string; location?: string; status?: string; - employment?: string; + /** Alles Weitere — siehe lib/report-criteria.ts. */ + criteria?: Criteria; }; +/** + * Macht aus den Kriterien Bedingungen auf `employees`. + * + * Bewusst Zeile für Zeile statt über eine Tabelle Schlüssel→Spalte: so prüft + * der Compiler jeden Wert gegen den Spaltentyp. Die Umwandlungen sind keine + * blinden Zusicherungen — `parseCriteria` hat den Wert vorher gegen dieselbe + * Werteliste geprüft, aus der auch der Aufzählungstyp besteht. + * + * Nicht hier: Wochentag und Angehörige (siehe passtImSpeicher), Einheit + * (Teilbaum) und Status (zum Stichtag abgeleitet) — die drei kann keine + * Spaltenbedingung ausdrücken. + */ +export function applyCriteria( + q: SelectQueryBuilder, + c: Criteria +): SelectQueryBuilder { + const a = c.auswahl; + if (a.employment) q = q.where("employment_type", "=", a.employment as EmploymentType); + if (a.contract) q = q.where("contract_type", "=", a.contract as ContractType); + if (a.worker) q = q.where("worker_type", "=", a.worker as WorkerType); + if (a.kv) q = q.where("collective_agreement", "=", a.kv as CollectiveAgreement); + if (a.paygrade) q = q.where("paygrade", "=", a.paygrade as PaygradeType); + if (a.source) q = q.where("source", "=", a.source as SourceType); + if (a.gender) q = q.where("gender", "=", a.gender as GenderType); + if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt); + if (a.absence) q = q.where("absence_type", "=", a.absence); + + const j = c.jaNein; + if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja"); + if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja"); + if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja"); + if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja"); + + // Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich + // mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung. + if (c.zeitraum.entry?.von) q = q.where("entry_date", ">=", c.zeitraum.entry.von); + if (c.zeitraum.entry?.bis) q = q.where("entry_date", "<=", c.zeitraum.entry.bis); + if (c.zeitraum.exit?.von) q = q.where("exit_date", ">=", c.zeitraum.exit.von); + if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis); + if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von); + if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis); + + if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon); + if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis); + + return q; +} + export type SnapshotFilters = ReportFilters & { asOf?: string }; export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string }; @@ -42,7 +113,7 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na export async function loadOrgLookups(tx: Tx): Promise<{ lookups: OrgLookups; orgMaps: OrgMaps; - divisions: { id: string; name: string }[]; + units: UnitOption[]; locations: { id: string; name: string }[]; }> { const orgMaps = await loadOrgMaps(tx); @@ -51,10 +122,15 @@ export async function loadOrgLookups(tx: Tx): Promise<{ return { lookups: lookupsFromOrgMaps(orgMaps, locations), orgMaps, - // Als Filter angeboten wird die oberste Ebene unter der Gesellschaft — - // das, was im Altmodell „Bereich" hiess. Der Filter greift auf den - // ganzen Teilbaum. - divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })), + // Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung + // auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen. + // Der Filter greift immer auf den ganzen Teilbaum darunter. + units: orgMaps.unitList.map((u) => ({ + id: u.id, + name: u.name, + depth: orgMaps.depthOf.get(u.id) ?? 0, + unitType: u.unit_type, + })), locations, }; } @@ -105,8 +181,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P function snapshotQuery() { let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id"); if (filters.location) q = q.where("location_id", "=", filters.location); - if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType); - return q; + return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA); } const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ @@ -121,6 +196,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P // Bereich selbst nur die Bereichsleitung sitzt. const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; + const criteria = filters.criteria ?? LEERE_CRITERIA; const withDerivedStatus: ReportEmployee[] = []; for (const e of data) { const placement = placements.get(e.id); @@ -128,6 +204,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P // sitzt aber auf keiner Planstelle mehr. const orgUnitId = placement?.current ? placement.orgUnitId : null; if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue; + if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue; withDerivedStatus.push({ id: e.id, diff --git a/lib/reports.ts b/lib/reports.ts index 24d70a3..9d4c97b 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -108,6 +108,12 @@ export type ReportEmployee = { // drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und // welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette // und wird einmal vorberechnet. +/** + * Eine Organisationseinheit als Filterwert — jede Ebene, nicht nur die + * Bereiche. `depth` dient der Einrückung in der Auswahlliste. + */ +export type UnitOption = { id: string; name: string; depth: number; unitType: string }; + export type OrgLookups = { divisionName: Map; departmentName: Map; diff --git a/tests/unit/report-criteria.test.ts b/tests/unit/report-criteria.test.ts new file mode 100644 index 0000000..4193d2d --- /dev/null +++ b/tests/unit/report-criteria.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + anzahlKriterien, + AUSWAHL_KRITERIEN, + JANEIN_KRITERIEN, + kriterienParameter, + LEERE_CRITERIA, + parseCriteria, + passtImSpeicher, + ZEITRAUM_KRITERIEN, +} from "@/lib/report-criteria"; + +function aus(params: Record) { + return parseCriteria((k) => params[k]); +} + +describe("parseCriteria", () => { + it("nimmt bekannte Werte an", () => { + const c = aus({ employment: "Teilzeit", paygrade: "C", gender: "w" }); + expect(c.auswahl).toEqual({ employment: "Teilzeit", paygrade: "C", gender: "w" }); + }); + + it("verwirft Werte, die nicht in der Liste stehen", () => { + // Die Werte landen in SQL-Vergleichen und im Dateinamen des Downloads, + // also in einem Content-Disposition-Header — was nicht in der Liste + // steht, darf nicht durchkommen. + const c = aus({ employment: "Vollzeit'; drop table employees; --", paygrade: "Z", gender: "x" }); + expect(c.auswahl).toEqual({}); + }); + + it("nimmt ja/nein an und sonst nichts", () => { + const c = aus({ dienstwagen: "ja", betriebsrat: "vielleicht", clevel: "nein" }); + expect(c.jaNein).toEqual({ dienstwagen: "ja", clevel: "nein" }); + }); + + it("prüft Zeiträume auf echte Kalenderdaten", () => { + const c = aus({ entryFrom: "2024-01-01", entryTo: "2024-02-31", exitTo: "gestern" }); + // Der 31. Februar ist kein Datum, auch wenn er wie eines aussieht. + expect(c.zeitraum.entry).toEqual({ von: "2024-01-01", bis: undefined }); + expect(c.zeitraum.exit).toBeUndefined(); + }); + + it("nimmt Stunden mit Komma und weist Unsinn ab", () => { + expect(aus({ hoursFrom: "20,5" }).stundenVon).toBe(20.5); + expect(aus({ hoursFrom: "-5" }).stundenVon).toBeUndefined(); + expect(aus({ hoursTo: "500" }).stundenBis).toBeUndefined(); + expect(aus({ hoursTo: "keine" }).stundenBis).toBeUndefined(); + }); + + it("liefert ohne Parameter nichts Gesetztes", () => { + expect(anzahlKriterien(aus({}))).toBe(0); + expect(anzahlKriterien(LEERE_CRITERIA)).toBe(0); + }); + + it("zählt jedes gesetzte Kriterium einzeln", () => { + const c = aus({ employment: "Teilzeit", dienstwagen: "ja", entryFrom: "2024-01-01", entryTo: "2024-12-31", hoursTo: "30" }); + expect(anzahlKriterien(c)).toBe(5); + }); +}); + +describe("kriterienParameter", () => { + it("nennt jeden Parameter, den parseCriteria liest", () => { + // „Zurücksetzen" räumt anhand dieser Liste auf. Fehlt darin ein Name, + // bleibt ein unsichtbares Kriterium in der Adresszeile stehen und + // schränkt den Export weiter ein, ohne dass es jemand sieht. + const namen = kriterienParameter(); + 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.flatMap((k) => [[`${k.key}From`, "2024-01-01"], [`${k.key}To`, "2024-12-31"]])), + hoursFrom: "10", + hoursTo: "38.5", + }; + expect(anzahlKriterien(aus(alle))).toBeGreaterThan(0); + + const uebrig = aus(Object.fromEntries(Object.entries(alle).filter(([k]) => !namen.includes(k)))); + expect(anzahlKriterien(uebrig)).toBe(0); + }); +}); + +describe("passtImSpeicher", () => { + const zeile = { work_days: ["Mo", "Di", "Mi"], dependentsCount: 2 }; + + it("lässt ohne Kriterien alles durch", () => { + expect(passtImSpeicher(zeile, LEERE_CRITERIA)).toBe(true); + }); + + it("prüft den Wochentag gegen die Arbeitstage", () => { + expect(passtImSpeicher(zeile, aus({ weekday: "Di" }))).toBe(true); + expect(passtImSpeicher(zeile, aus({ weekday: "Fr" }))).toBe(false); + }); + + it("unterscheidet mit und ohne Angehörige", () => { + expect(passtImSpeicher(zeile, aus({ dependents: "ja" }))).toBe(true); + expect(passtImSpeicher(zeile, aus({ dependents: "nein" }))).toBe(false); + expect(passtImSpeicher({ ...zeile, dependentsCount: 0 }, aus({ dependents: "nein" }))).toBe(true); + expect(passtImSpeicher({ ...zeile, dependentsCount: 0 }, aus({ dependents: "ja" }))).toBe(false); + }); +});