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); + }); +});