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:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, string | string[] | undefined> & {
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -104,22 +104,12 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* „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. */}
|
||||
<select
|
||||
aria-label="Nach Dienstwagen filtern"
|
||||
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>
|
||||
{/* 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. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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<string, undefined> = {};
|
||||
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<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);
|
||||
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) {
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<select
|
||||
aria-label="Nach Bereich filtern"
|
||||
aria-label="Nach Organisationseinheit filtern"
|
||||
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
|
||||
onChange={(e) => updateParams({ division: e.target.value })}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<option value="">Alle Bereiche</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
<option value="">Alle Organisationseinheiten</option>
|
||||
{units.map((u) => (
|
||||
// Einrückung mit geschützten Leerzeichen: ein <optgroup> je
|
||||
// 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>
|
||||
))}
|
||||
</select>
|
||||
@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{mode === "snapshot" && (
|
||||
<KriterienBlock criteria={props.criteria} onChange={updateParams} onReset={resetKriterien} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
|
||||
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>;
|
||||
|
||||
99
tests/unit/report-criteria.test.ts
Normal file
99
tests/unit/report-criteria.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user