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