Let the export select on everything the data model holds
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 <noreply@anthropic.com>
This commit is contained in:
169
lib/report-criteria.ts
Normal file
169
lib/report-criteria.ts
Normal file
@@ -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 `<key>From` und `<key>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<string, string>;
|
||||
/** Ja/Nein je Schlüssel aus JANEIN_KRITERIEN. */
|
||||
jaNein: Record<string, JaNein>;
|
||||
/** ISO-Daten je Schlüssel aus ZEITRAUM_KRITERIEN, jeweils von/bis. */
|
||||
zeitraum: Record<string, { von?: string; bis?: string }>;
|
||||
/** 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<string, string> = {};
|
||||
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<string, JaNein> = {};
|
||||
for (const k of JANEIN_KRITERIEN) {
|
||||
const roh = get(k.key);
|
||||
if (roh === "ja" || roh === "nein") jaNein[k.key] = roh;
|
||||
}
|
||||
|
||||
const zeitraum: Record<string, { von?: string; bis?: string }> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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<O>(
|
||||
q: SelectQueryBuilder<Schema, "employees", O>,
|
||||
c: Criteria
|
||||
): SelectQueryBuilder<Schema, "employees", O> {
|
||||
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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
departmentName: Map<string, string>;
|
||||
|
||||
Reference in New Issue
Block a user