From 5c310c3a58bced359f257a00d07e2913f8843a32 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 7 Sep 2026 10:02:15 +0200 Subject: [PATCH] Let the employee list be sorted A-Z or Z-A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/(app)/employees/page.tsx | 11 ++++-- components/employees/EmployeeFilters.tsx | 18 ++++++++++ lib/employee-sort.ts | 40 +++++++++++++++++++++ tests/unit/employee-sort.test.ts | 45 ++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 lib/employee-sort.ts create mode 100644 tests/unit/employee-sort.test.ts diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 7b6eb46..eb691b9 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -9,6 +9,7 @@ import { currentUserId } from "@/lib/auth/session"; import { sql, withUser } from "@/lib/db"; import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json"; import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; +import { parseSortierung, richtung } from "@/lib/employee-sort"; import { derivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, fmtName, todayIso } from "@/lib/format"; import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org"; @@ -22,6 +23,7 @@ type SearchParams = { division?: string; status?: string; location?: string; + sort?: string; page?: string; }; @@ -35,6 +37,9 @@ function pageHref(params: SearchParams, page: number): string { if (params.division) sp.set("division", params.division); if (params.status) sp.set("status", params.status); if (params.location) sp.set("location", params.location); + // Ohne das kippte die Liste beim Blättern zurück auf A–Z, und Seite 2 + // zeigte Namen, die auf Seite 1 schon standen. + if (params.sort) sp.set("sort", params.sort); sp.set("page", String(page)); return `/employees?${sp.toString()}`; } @@ -42,6 +47,7 @@ function pageHref(params: SearchParams, page: number): string { export default async function EmployeesPage({ searchParams }: EmployeesPageProps) { const params = await searchParams; const page = Math.max(1, Number(params.page ?? "1") || 1); + const sortierung = parseSortierung(params.sort); const today = todayIso(); // Comma-separated, so a dashboard tile can link here with the same @@ -176,8 +182,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps ]) // Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die // Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei - // Seiten erscheinen oder auf keiner. - .orderBy("last_name") + // Seiten erscheinen oder auf keiner. Die Kennung bleibt deshalb + // aufsteigend, auch wenn der Nachname sich dreht. + .orderBy("last_name", richtung(sortierung)) .orderBy("id") .limit(PAGE_SIZE) .offset((page - 1) * PAGE_SIZE) diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 72757ad..a5e3059 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -4,6 +4,7 @@ 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. */ @@ -104,6 +105,23 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr ))} + {/* 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. */} + {/* 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 — diff --git a/lib/employee-sort.ts b/lib/employee-sort.ts new file mode 100644 index 0000000..264355a --- /dev/null +++ b/lib/employee-sort.ts @@ -0,0 +1,40 @@ +// Wie die Mitarbeiterliste sortiert wird. +// +// Wie die Filter steht auch die Sortierung in der Adresse und nicht im +// Browser: die Liste wird auf dem Server gebaut und seitenweise geholt. Wer +// im Browser sortierte, ordnete nur die fünfzehn Zeilen der aktuellen Seite +// um — bei 867 Personen wäre das eine Sortierung, die nach dem Blättern +// etwas anderes zeigt als versprochen. +// +// Sortiert wird nach dem **Nachnamen**, weil die Liste danach gelesen wird: +// die Spalte zeigt „Aigner, Manuel", und wer jemanden sucht, sucht ihn unter +// A. Postgres läuft mit österreichischer Sortierregel, deshalb steht Ö bei O +// und nicht am Ende des Alphabets. + +export const SORTIERUNGEN = [ + { value: "az", label: "Name A–Z" }, + { value: "za", label: "Name Z–A" }, +] as const; + +export type Sortierung = (typeof SORTIERUNGEN)[number]["value"]; + +export const STANDARD_SORTIERUNG: Sortierung = "az"; + +/** Alles, was nicht in der Liste steht, führt auf den Standard zurück. */ +export function parseSortierung(wert: string | undefined): Sortierung { + return SORTIERUNGEN.some((s) => s.value === wert) ? (wert as Sortierung) : STANDARD_SORTIERUNG; +} + +/** + * Die Richtung für die Abfrage. + * + * Nur der Nachname dreht sich. Das zweite Kriterium — die Kennung — bleibt + * aufsteigend: es entscheidet nur bei gleichem Nachnamen und dient allein + * dazu, die Reihenfolge über die Seitengrenzen hinweg eindeutig zu halten. + * Drehte es mit, wäre die Sortierung immer noch eindeutig, aber die vierzehn + * Winkler stünden in umgekehrter Reihenfolge zueinander, ohne dass jemand + * das verlangt hätte. + */ +export function richtung(sortierung: Sortierung): "asc" | "desc" { + return sortierung === "za" ? "desc" : "asc"; +} diff --git a/tests/unit/employee-sort.test.ts b/tests/unit/employee-sort.test.ts new file mode 100644 index 0000000..b05a724 --- /dev/null +++ b/tests/unit/employee-sort.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { SORTIERUNGEN, STANDARD_SORTIERUNG, parseSortierung, richtung } from "@/lib/employee-sort"; + +// Die Sortierung steht in der Adresse, und dort kann alles stehen: eine +// veraltete Verknüpfung, ein Tippfehler, ein abgeschnittener Wert. Sie darf +// die Liste weder leeren noch in einer Reihenfolge zeigen, die niemand +// gewählt hat — und schon gar nicht in die Abfrage durchschlagen. + +describe("parseSortierung", () => { + it("nimmt jede angebotene Sortierung", () => { + // Aus der Liste abgeleitet, nicht abgeschrieben: käme eine dritte + // Sortierung dazu, prüft dieser Test sie mit, ohne dass jemand daran + // denken muss. + for (const s of SORTIERUNGEN) { + expect(parseSortierung(s.value)).toBe(s.value); + } + }); + + it("fällt ohne Angabe auf A–Z zurück", () => { + expect(parseSortierung(undefined)).toBe(STANDARD_SORTIERUNG); + expect(parseSortierung("")).toBe(STANDARD_SORTIERUNG); + expect(STANDARD_SORTIERUNG).toBe("az"); + }); + + it("lässt sich nichts unterschieben", () => { + expect(parseSortierung("AZ")).toBe(STANDARD_SORTIERUNG); + expect(parseSortierung("last_name desc")).toBe(STANDARD_SORTIERUNG); + expect(parseSortierung("id; drop table employees")).toBe(STANDARD_SORTIERUNG); + }); +}); + +describe("richtung", () => { + it("dreht nur bei Z–A", () => { + expect(richtung("az")).toBe("asc"); + expect(richtung("za")).toBe("desc"); + }); + + it("liefert für jede Sortierung eine gültige Richtung", () => { + // Die Richtung geht unmittelbar in die Abfrage. Ein anderer Wert als + // asc oder desc wäre dort ein Fehler zur Laufzeit, nicht beim Bauen. + for (const s of SORTIERUNGEN) { + expect(["asc", "desc"]).toContain(richtung(s.value)); + } + }); +});