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:
2026-08-12 12:40:47 +02:00
parent f14f1cb8df
commit 0b8f874fa5
10 changed files with 586 additions and 102 deletions

View File

@@ -20,8 +20,6 @@ type SearchParams = {
division?: string; division?: string;
status?: string; status?: string;
location?: string; location?: string;
/** "ja" | "nein" | "elektro" | "verbrenner" — alles andere wird übergangen. */
dienstwagen?: string;
page?: string; page?: string;
}; };
@@ -35,7 +33,6 @@ function pageHref(params: SearchParams, page: number): string {
if (params.division) sp.set("division", params.division); if (params.division) sp.set("division", params.division);
if (params.status) sp.set("status", params.status); if (params.status) sp.set("status", params.status);
if (params.location) sp.set("location", params.location); if (params.location) sp.set("location", params.location);
if (params.dienstwagen) sp.set("dienstwagen", params.dienstwagen);
sp.set("page", String(page)); sp.set("page", String(page));
return `/employees?${sp.toString()}`; 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); 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; return q;
}; };

View File

@@ -15,11 +15,15 @@ import {
sumValues, sumValues,
totalForRows, totalForRows,
} from "@/lib/reports"; } from "@/lib/reports";
import { parseCriteria } from "@/lib/report-criteria";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { currentUserId } from "@/lib/auth/session"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db"; 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<string, string | string[] | undefined> & {
mode?: string; mode?: string;
measure?: string; measure?: string;
group?: string; group?: string;
@@ -27,7 +31,6 @@ type SearchParams = {
division?: string; division?: string;
location?: string; location?: string;
status?: string; status?: string;
employment?: string;
asOf?: string; asOf?: string;
eventType?: string; eventType?: string;
from?: string; from?: string;
@@ -50,6 +53,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
const measure = parseMeasure(params.measure); const measure = parseMeasure(params.measure);
const group = parseGroupDimension(params.group); const group = parseGroupDimension(params.group);
const split = parseSplitDimension(params.split); 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 // 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 // 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 // Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste // Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
// Teil dieser Seite war. // Teil dieser Seite war.
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => { const { lookups, units, 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 Promise.all([
loadOrgLookups(tx), loadOrgLookups(tx),
mode === "events" mode === "events"
? loadEventHistory(tx, { ? loadEventHistory(tx, {
@@ -78,7 +82,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
division: params.division, division: params.division,
location: params.location, location: params.location,
status: params.status, status: params.status,
employment: params.employment, criteria,
asOf, asOf,
}) })
: Promise.resolve([]), : Promise.resolve([]),
@@ -91,7 +95,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
.execute() .execute()
: Promise.resolve([]), : Promise.resolve([]),
]); ]);
return { lookups, divisions, locations, events, employees, savedReports }; return { lookups, units, locations, events, employees, savedReports };
}); });
if (mode === "events") { if (mode === "events") {
@@ -108,7 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
rows={rows} rows={rows}
total={sumValues(rows)} total={sumValues(rows)}
recordCount={events.length} recordCount={events.length}
divisions={divisions} units={units}
locations={locations} locations={locations}
savedReports={savedReports} savedReports={savedReports}
/> />
@@ -130,12 +134,12 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
division: params.division ?? "", division: params.division ?? "",
location: params.location ?? "", location: params.location ?? "",
status: params.status ?? "", status: params.status ?? "",
employment: params.employment ?? "",
}} }}
criteria={criteria}
rows={rows} rows={rows}
total={totalForRows(rows, measure)} total={totalForRows(rows, measure)}
recordCount={employees.length} recordCount={employees.length}
divisions={divisions} units={units}
locations={locations} locations={locations}
savedReports={savedReports} savedReports={savedReports}
/> />

View File

@@ -4,11 +4,12 @@ import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org"; import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement"; 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 { 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 { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db"; 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 // Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
// über die Planstelle und die abgeleitete Berichtslinie. // über die Planstelle und die abgeleitete Berichtslinie.
@@ -35,7 +36,7 @@ export async function GET(request: NextRequest) {
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined, status: params.get("status") ?? undefined,
employment: params.get("employment") ?? undefined, criteria: parseCriteria((k) => params.get(k)),
}; };
const statuses = parseStatuses(filters.status); const statuses = parseStatuses(filters.status);
@@ -48,9 +49,10 @@ export async function GET(request: NextRequest) {
function employeeQuery() { function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id"); let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location); 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); 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([ 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. // Export für "Produktion" nur die Bereichsleitung.
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const enriched: EmployeeRow[] = employees.flatMap((e) => { const enriched: EmployeeRow[] = employees.flatMap((e) => {
const placement = placements.get(e.id); const placement = placements.get(e.id);
const orgUnitId = placement?.current ? placement.orgUnitId : null; const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return []; if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
return [{ return [{
...e, ...e,
org_unit_id: orgUnitId, org_unit_id: orgUnitId,

View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { parseCriteria } from "@/lib/report-criteria";
import { import {
aggregateEvents, aggregateEvents,
aggregateReport, aggregateReport,
@@ -69,7 +70,7 @@ export async function GET(request: NextRequest) {
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined, status: params.get("status") ?? undefined,
employment: params.get("employment") ?? undefined, criteria: parseCriteria((k) => params.get(k)),
asOf, asOf,
}); });
rows = aggregateReport(employees, measure, group, split, lookups, asOf); rows = aggregateReport(employees, measure, group, split, lookups, asOf);

View File

@@ -104,22 +104,12 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
</option> </option>
))} ))}
</select> </select>
{/* „Wer hat einen Dienstwagen" ist eine wiederkehrende Frage — als {/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
Filter beantwortet sie sich mit einem Klick, statt über einen jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
Export mit Nachbearbeitung. E-KFZ getrennt, weil das die Frage Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —
hinter der Frage ist. */} dort lässt sich die Auswahl auch exportieren, was der eigentliche
<select Zweck der Frage war. In dieser Leiste, die vor allem zum Suchen da
aria-label="Nach Dienstwagen filtern" ist, wäre er ein Sonderfall unter vielen gleichrangigen. */}
defaultValue={searchParams.get("dienstwagen") ?? ""}
onChange={(e) => updateParam("dienstwagen", e.target.value)}
className={FILTER_SELECT_CLASS}
>
<option value="">Dienstwagen: alle</option>
<option value="ja">Mit Dienstwagen</option>
<option value="elektro">Nur E-KFZ</option>
<option value="verbrenner">Nur Verbrenner</option>
<option value="nein">Ohne Dienstwagen</option>
</select>
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "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 Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@@ -10,6 +10,14 @@ import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
import {
anzahlKriterien,
AUSWAHL_KRITERIEN,
JANEIN_KRITERIEN,
kriterienParameter,
ZEITRAUM_KRITERIEN,
type Criteria,
} from "@/lib/report-criteria";
import { import {
AVERAGE_MEASURES, AVERAGE_MEASURES,
EVENT_DATE_OPEN, EVENT_DATE_OPEN,
@@ -27,6 +35,7 @@ import {
type GroupDimension, type GroupDimension,
type Measure, type Measure,
type ReportRow, type ReportRow,
type UnitOption,
} from "@/lib/reports"; } from "@/lib/reports";
import type { HistoryEventType } from "@/lib/supabase/types"; import type { HistoryEventType } from "@/lib/supabase/types";
@@ -40,7 +49,7 @@ type CommonProps = {
rows: ReportRow[]; rows: ReportRow[];
total: number; total: number;
recordCount: number; recordCount: number;
divisions: OrgOption[]; units: UnitOption[];
locations: OrgOption[]; locations: OrgOption[];
savedReports: SavedReport[]; savedReports: SavedReport[];
}; };
@@ -51,7 +60,8 @@ type SnapshotProps = CommonProps & {
group: GroupDimension; group: GroupDimension;
split: GroupDimension | ""; split: GroupDimension | "";
asOf: string; asOf: string;
filters: { division: string; location: string; status: string; employment: string }; filters: { division: string; location: string; status: string };
criteria: Criteria;
}; };
type EventsProps = CommonProps & { type EventsProps = CommonProps & {
@@ -64,6 +74,144 @@ type EventsProps = CommonProps & {
type ReportsPageClientProps = SnapshotProps | EventsProps; 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<string, string | undefined>) => void;
onReset: () => void;
}) {
const [offen, setOffen] = useState(false);
const anzahl = anzahlKriterien(criteria);
return (
<div className="mt-2 border-t border-border-subtle pt-2">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setOffen(!offen)}
aria-expanded={offen}
className="flex items-center gap-1 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${offen ? "rotate-180" : ""}`} />
Weitere Kriterien
{anzahl > 0 && <span className="rounded-full bg-brand-500 px-1.5 text-[10px] text-white">{anzahl}</span>}
</button>
{anzahl > 0 && (
<button
type="button"
onClick={onReset}
className="rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Zurücksetzen
</button>
)}
</div>
{offen && (
<div className="mt-3 flex flex-col gap-2">
{AUSWAHL_KRITERIEN.map((k) => (
<select
key={k.key}
aria-label={`Nach ${k.label} filtern`}
value={criteria.auswahl[k.key] ?? ""}
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
className={CONTROL_CLASS}
>
<option value="">{k.alle}</option>
{k.optionen.map((o) => (
<option key={o.wert} value={o.wert}>
{o.label}
</option>
))}
</select>
))}
<fieldset className="rounded border border-border px-3 py-2">
<legend className="mb-1.5 text-xs font-semibold text-ink-muted">Merkmale</legend>
<div className="flex flex-col gap-1.5">
{JANEIN_KRITERIEN.map((k) => (
<div key={k.key} className="flex items-center justify-between gap-2 text-sm text-ink-body">
<span>{k.label}</span>
<select
aria-label={`${k.label}: ja oder nein`}
value={criteria.jaNein[k.key] ?? ""}
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
className="rounded border border-border px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<option value="">egal</option>
<option value="ja">ja</option>
<option value="nein">nein</option>
</select>
</div>
))}
</div>
</fieldset>
{ZEITRAUM_KRITERIEN.map((k) => (
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
label="Von"
dense
type="date"
value={criteria.zeitraum[k.key]?.von ?? ""}
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
/>
<TextField
label="Bis"
dense
type="date"
value={criteria.zeitraum[k.key]?.bis ?? ""}
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
/>
</div>
</fieldset>
))}
<fieldset className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">Wochenstunden</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
label="Ab"
dense
type="number"
step="0.5"
value={criteria.stundenVon?.toString() ?? ""}
onChange={(v) => onChange({ hoursFrom: v || undefined })}
/>
<TextField
label="Bis"
dense
type="number"
step="0.5"
value={criteria.stundenBis?.toString() ?? ""}
onChange={(v) => onChange({ hoursTo: v || undefined })}
/>
</div>
</fieldset>
<p className="text-xs text-ink-muted">
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
</p>
</div>
)}
</div>
);
}
function formatValue(measure: Measure, value: number): string { function formatValue(measure: Measure, value: number): string {
if (measure === "headcount") return String(Math.round(value)); if (measure === "headcount") return String(Math.round(value));
if (measure === "fte") return value.toFixed(1); if (measure === "fte") return value.toFixed(1);
@@ -73,7 +221,7 @@ function formatValue(measure: Measure, value: number): string {
} }
export function ReportsPageClient(props: ReportsPageClientProps) { 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 router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -103,6 +251,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
updateParams({ status: next.length > 0 ? next.join(",") : undefined }); updateParams({ status: next.length > 0 ? next.join(",") : undefined });
} }
function resetKriterien() {
const patch: Record<string, undefined> = {};
for (const p of kriterienParameter()) patch[p] = undefined;
updateParams(patch);
}
function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) { function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) {
const sp = new URLSearchParams({ mode }); const sp = new URLSearchParams({ mode });
if (preset.measure) sp.set("measure", preset.measure); if (preset.measure) sp.set("measure", preset.measure);
@@ -126,10 +280,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
showToast("Bitte einen Namen angeben.", "error"); showToast("Bitte einen Namen angeben.", "error");
return; return;
} }
const config = // Auch hier die ganze Adresszeile: ein gespeicherter Bericht, der die
mode === "snapshot" // Hälfte der Kriterien vergisst, führt beim nächsten Öffnen still zu
? { mode, measure: props.measure, group: props.group, split: props.split, asOf: props.asOf, ...props.filters } // anderen Zahlen.
: { mode, group: props.eventGroup, split: props.eventSplit, eventType: props.eventType, ...props.eventFilters }; const config: Record<string, string> = Object.fromEntries(searchParams.entries());
config.mode = mode;
config.group = mode === "snapshot" ? props.group : props.eventGroup;
if (mode === "snapshot") config.measure = props.measure;
setSavingReport(true); setSavingReport(true);
const result = await saveReport({ name: newReportName.trim(), config }); const result = await saveReport({ name: newReportName.trim(), config });
setSavingReport(false); 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 { function reportExportHref(format: "csv" | "xlsx"): string {
const sp = new URLSearchParams(); const sp = new URLSearchParams(searchParams.toString());
sp.set("format", format); sp.set("format", format);
sp.set("mode", mode); sp.set("mode", mode);
if (mode === "snapshot") { // Die Vorgaben stehen nicht zwingend in der Adresszeile; die Route
sp.set("measure", props.measure); // braucht sie aber, um dieselbe Auswertung zu bauen.
sp.set("group", props.group); sp.set("group", mode === "snapshot" ? props.group : props.eventGroup);
if (props.split) sp.set("split", props.split); if (mode === "snapshot") sp.set("measure", props.measure);
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);
}
return `/api/export/report?${sp.toString()}`; return `/api/export/report?${sp.toString()}`;
} }
function fullExportHref(format: "csv" | "xlsx"): string { function fullExportHref(format: "csv" | "xlsx"): string {
if (mode === "snapshot") { return exportHref(mode === "snapshot" ? "/api/export/employees" : "/api/export/events", format);
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()}`;
} }
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure); const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
@@ -367,15 +513,19 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<select <select
aria-label="Nach Bereich filtern" aria-label="Nach Organisationseinheit filtern"
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division} value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
onChange={(e) => updateParams({ division: e.target.value })} onChange={(e) => updateParams({ division: e.target.value })}
className={CONTROL_CLASS} className={CONTROL_CLASS}
> >
<option value="">Alle Bereiche</option> <option value="">Alle Organisationseinheiten</option>
{divisions.map((d) => ( {units.map((u) => (
<option key={d.id} value={d.id}> // Einrückung mit geschützten Leerzeichen: ein <optgroup> je
{d.name} // Ebene ginge nicht, weil Einheiten dort auswählbar bleiben
// müssen — eine Abteilung ist selbst ein Filterwert.
<option key={u.id} value={u.id}>
{"  ".repeat(u.depth)}
{u.name}
</option> </option>
))} ))}
</select> </select>
@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
))} ))}
</div> </div>
</fieldset> </fieldset>
<select
aria-label="Nach Beschäftigungsart filtern"
value={props.filters.employment}
onChange={(e) => updateParams({ employment: e.target.value })}
className={CONTROL_CLASS}
>
<option value="">Alle Beschäftigungsarten</option>
<option value="Vollzeit">Vollzeit</option>
<option value="Teilzeit">Teilzeit</option>
</select>
</> </>
)} )}
</div> </div>
{mode === "snapshot" && (
<KriterienBlock criteria={props.criteria} onChange={updateParams} onReset={resetKriterien} />
)}
</div> </div>
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">

169
lib/report-criteria.ts Normal file
View 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;
}

View File

@@ -1,8 +1,30 @@
import type { SelectQueryBuilder } from "kysely";
import type { Schema } from "./db/schema";
import type { Tx } from "./db"; import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement"; import { loadPlacements } from "./placement";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
import type { EmploymentType, HistoryEventType } from "./supabase/types"; 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 // 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 // what "the current view" means — same filters, same stichtag/event-window
@@ -12,9 +34,58 @@ export type ReportFilters = {
division?: string; division?: string;
location?: string; location?: string;
status?: 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 SnapshotFilters = ReportFilters & { asOf?: string };
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: 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<{ export async function loadOrgLookups(tx: Tx): Promise<{
lookups: OrgLookups; lookups: OrgLookups;
orgMaps: OrgMaps; orgMaps: OrgMaps;
divisions: { id: string; name: string }[]; units: UnitOption[];
locations: { id: string; name: string }[]; locations: { id: string; name: string }[];
}> { }> {
const orgMaps = await loadOrgMaps(tx); const orgMaps = await loadOrgMaps(tx);
@@ -51,10 +122,15 @@ export async function loadOrgLookups(tx: Tx): Promise<{
return { return {
lookups: lookupsFromOrgMaps(orgMaps, locations), lookups: lookupsFromOrgMaps(orgMaps, locations),
orgMaps, orgMaps,
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft — // Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den // auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen.
// ganzen Teilbaum. // Der Filter greift immer auf den ganzen Teilbaum darunter.
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })), units: orgMaps.unitList.map((u) => ({
id: u.id,
name: u.name,
depth: orgMaps.depthOf.get(u.id) ?? 0,
unitType: u.unit_type,
})),
locations, locations,
}; };
} }
@@ -105,8 +181,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
function snapshotQuery() { function snapshotQuery() {
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id"); let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location); if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType); return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
return q;
} }
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ 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. // Bereich selbst nur die Bereichsleitung sitzt.
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const withDerivedStatus: ReportEmployee[] = []; const withDerivedStatus: ReportEmployee[] = [];
for (const e of data) { for (const e of data) {
const placement = placements.get(e.id); 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. // sitzt aber auf keiner Planstelle mehr.
const orgUnitId = placement?.current ? placement.orgUnitId : null; const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue; if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue;
withDerivedStatus.push({ withDerivedStatus.push({
id: e.id, id: e.id,

View File

@@ -108,6 +108,12 @@ export type ReportEmployee = {
// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und // drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und
// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette // welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette
// und wird einmal vorberechnet. // 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 = { export type OrgLookups = {
divisionName: Map<string, string>; divisionName: Map<string, string>;
departmentName: Map<string, string>; departmentName: Map<string, string>;

View File

@@ -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<string, string>) {
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);
});
});