Sorting lives in the URL, not the browser. The list is built on the server and fetched a page at a time, so a client-side sort would only reorder the fifteen rows on screen — with 867 people that promises an alphabetical list and delivers something else on page 2. The sort key is the surname, because that is how the column reads: "Aigner, Manuel", and whoever looks for someone looks under A. Postgres runs with the Austrian collation, so Ö sorts with O rather than at the end of the alphabet. Only the surname reverses. The id stays ascending: it decides nothing except ties, and it exists to keep the order total across page boundaries. Reversing it too would still be deterministic but would flip the fourteen Winklers relative to each other for no reason anyone asked for. The select sits in the filter bar rather than in a clickable column header — a header would suggest it sorts what is on screen. Verified: compiled SQL is `order by last_name desc, id` for Z-A; the parse and direction rules are covered by tests that were mutation-checked (breaking each rule turns them red). Not verified in the browser — the login goes through the company account, and the Supabase instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
5.4 KiB
TypeScript
134 lines
5.4 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";
|
||
import { SORTIERUNGEN, STANDARD_SORTIERUNG } from "@/lib/employee-sort";
|
||
|
||
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>
|
||
{/* Die Sortierung steht bewusst neben den Filtern und nicht in der
|
||
Tabellenüberschrift: sortiert wird über den ganzen Bestand, nicht
|
||
über die fünfzehn Zeilen dieser Seite. Ein anklickbarer Spaltenkopf
|
||
verspräche das Gegenteil. Anders als die Filter hat sie keinen
|
||
leeren Eintrag — irgendeine Reihenfolge hat die Liste immer. */}
|
||
<select
|
||
aria-label="Liste sortieren"
|
||
defaultValue={searchParams.get("sort") ?? STANDARD_SORTIERUNG}
|
||
onChange={(e) => updateParam("sort", e.target.value)}
|
||
className={FILTER_SELECT_CLASS}
|
||
>
|
||
{SORTIERUNGEN.map((s) => (
|
||
<option key={s.value} value={s.value}>
|
||
{s.label}
|
||
</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>
|
||
);
|
||
}
|