Sort from the column headers, all seven of them
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m2s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m9s

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>
This commit is contained in:
2026-09-07 13:32:52 +02:00
parent 029b63009f
commit 405d708bc4
4 changed files with 428 additions and 90 deletions

View File

@@ -4,7 +4,6 @@ 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. */
@@ -105,23 +104,9 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
</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>
{/* 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 —