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>
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
"use client";
|
||
|
||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||
import { useEffect, useState } from "react";
|
||
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
||
import { SearchInput } from "@/components/ui/SearchInput";
|
||
|
||
type EmployeeFiltersProps = {
|
||
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */
|
||
units: { id: string; name: string; unit_type: string }[];
|
||
depthOf: Map<string, number>;
|
||
locations: { id: string; name: string }[];
|
||
};
|
||
|
||
// Aktiv and Langzeitabwesenheit are separate statuses — somebody on a
|
||
// long-term absence is employed but not active. The combined entry is
|
||
// offered explicitly, named after the two it selects rather than calling
|
||
// the pair "aktiv". The stored value is still 'Karenz' (see lib/absence.ts).
|
||
const STATUS_OPTIONS = [
|
||
{ value: "Aktiv", label: "Aktiv" },
|
||
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
||
{ value: "Aktiv,Karenz", label: "Aktiv + Langzeitabwesenheit (beschäftigt)" },
|
||
{ value: "Geplant", label: "Geplant" },
|
||
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||
] as const;
|
||
|
||
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) {
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const searchParams = useSearchParams();
|
||
const [q, setQ] = useState(searchParams.get("q") ?? "");
|
||
|
||
useEffect(() => {
|
||
const handle = setTimeout(() => {
|
||
const current = new URLSearchParams(searchParams.toString());
|
||
if (q) current.set("q", q);
|
||
else current.delete("q");
|
||
current.delete("page");
|
||
const next = current.toString();
|
||
if (next !== searchParams.toString()) router.push(`${pathname}?${next}`);
|
||
}, 300);
|
||
return () => clearTimeout(handle);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [q]);
|
||
|
||
function updateParam(key: string, value: string) {
|
||
const params = new URLSearchParams(searchParams.toString());
|
||
if (value) params.set(key, value);
|
||
else params.delete(key);
|
||
params.delete("page");
|
||
router.push(`${pathname}?${params.toString()}`);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<SearchInput label="Mitarbeiter:innen durchsuchen" placeholder="Name, Pers.-Nr., Titel…" value={q} onChange={setQ} />
|
||
{/* aria-label rather than a visible label: the filter bar is a single
|
||
horizontal row, and each select's first option already names it on
|
||
screen. */}
|
||
{/* Der ganze Baum, nicht nur die oberste Ebene: die Auswahl greift
|
||
jeweils auf die Einheit *und alles darunter*, weshalb sich damit
|
||
auch nach einer einzelnen Abteilung oder einem Team filtern lässt.
|
||
Eingerückt statt gruppiert, weil optgroup keine Verschachtelung
|
||
kennt und die Tiefe hier beliebig ist. */}
|
||
<select
|
||
aria-label="Nach Organisationseinheit filtern"
|
||
defaultValue={searchParams.get("division") ?? ""}
|
||
onChange={(e) => updateParam("division", e.target.value)}
|
||
className={FILTER_SELECT_CLASS}
|
||
>
|
||
<option value="">Alle Einheiten</option>
|
||
{units
|
||
.filter((u) => u.unit_type !== "Gesellschaft")
|
||
.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
|
||
{u.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
aria-label="Nach Status filtern"
|
||
defaultValue={searchParams.get("status") ?? ""}
|
||
onChange={(e) => updateParam("status", e.target.value)}
|
||
className={FILTER_SELECT_CLASS}
|
||
>
|
||
<option value="">Alle Status</option>
|
||
{STATUS_OPTIONS.map((s) => (
|
||
<option key={s.value} value={s.value}>
|
||
{s.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
aria-label="Nach Standort filtern"
|
||
defaultValue={searchParams.get("location") ?? ""}
|
||
onChange={(e) => updateParam("location", e.target.value)}
|
||
className={FILTER_SELECT_CLASS}
|
||
>
|
||
<option value="">Alle Standorte</option>
|
||
{locations.map((l) => (
|
||
<option key={l.id} value={l.id}>
|
||
{l.name}
|
||
</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>
|
||
);
|
||
}
|