The dropdown is gone; each column header is now a link that sorts by that column, with an arrow showing the direction. Clicking the column already sorted reverses it; clicking a different one starts ascending again — going from "Eintritt, newest first" to "Name" should give you names from A, not inherit the previous direction. Names sort by surname and then forename, as asked. Both parts reverse together: turning only the surname would give Z-A across surnames but A-Z within each one, which is visible immediately among the fifteen Aigner. Three of the seven columns are not on the employee row. Bereich and Team hang off the position, Standort off a lookup table, so they are fetched as correlated subqueries rather than joins. That is not a style preference: the same filter chain produces the page *and* the count, and a join onto position_assignments would double every person who has held more than one position over time — the line above the list would read 1,203 for 867 people. Bereich is the level below the company, so it needs to walk up from the unit. No recursion: org_unit_type has exactly four levels, so two hops up cover it. Everything sorts `nulls last`, otherwise reversing the direction floats every person without a position or location to the top. The expressions live in lib/employee-sort.ts rather than in the page so the generated SQL can be read in a test — the failure mode here is silent, the list still shows fifteen rows, just the wrong ones. Eighteen tests, and the rules are mutation-checked: dropping the forename, dropping the id tiebreaker, dropping `nulls last`, sorting the location by its uuid, and shortening the Bereich walk each turn them red. Not seen in a browser: login goes through the company account and the database is unreachable. Typecheck, lint, 458 tests and the build are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
4.8 KiB
TypeScript
119 lines
4.8 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>
|
||
{/* Sortiert wird nicht hier, sondern an den Spaltenköpfen der Liste.
|
||
Diese Leiste schränkt ein, *was* zu sehen ist; die Reihenfolge
|
||
gehört an die Spalte, die sie bestimmt. */}
|
||
{/* 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>
|
||
);
|
||
}
|