Files
alpenwerk-hr/components/employees/EmployeeFilters.tsx
Maximilian Stubhan 405d708bc4
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m2s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m9s
Sort from the column headers, all seven of them
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>
2026-09-07 13:33:09 +02:00

119 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}