diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx
index fdf7bd4..7849d76 100644
--- a/app/(app)/employees/page.tsx
+++ b/app/(app)/employees/page.tsx
@@ -9,7 +9,15 @@ 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 {
+ SORTIERFELDER,
+ naechsteRichtung,
+ sortiere,
+ parseFeld,
+ parseRichtung,
+ type Richtung,
+ type Sortierfeld,
+} 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";
@@ -24,6 +32,7 @@ type SearchParams = {
status?: string;
location?: string;
sort?: string;
+ dir?: string;
page?: string;
};
@@ -37,17 +46,36 @@ 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.
+ // Ohne das kippte die Liste beim Blättern zurück auf den Standard, und
+ // Seite 2 zeigte Namen, die auf Seite 1 schon standen.
if (params.sort) sp.set("sort", params.sort);
+ if (params.dir) sp.set("dir", params.dir);
sp.set("page", String(page));
return `/employees?${sp.toString()}`;
}
+/**
+ * Die Adresse hinter einem Spaltenkopf.
+ *
+ * Ohne `page`: nach dem Umsortieren steht auf Seite 7 etwas völlig anderes
+ * als vorher. Wer sortiert, will von vorne anfangen.
+ */
+function sortHref(params: SearchParams, feld: Sortierfeld, aktuell: Sortierfeld, richtung: Richtung): string {
+ const sp = new URLSearchParams();
+ if (params.q) sp.set("q", params.q);
+ if (params.division) sp.set("division", params.division);
+ if (params.status) sp.set("status", params.status);
+ if (params.location) sp.set("location", params.location);
+ sp.set("sort", feld);
+ sp.set("dir", naechsteRichtung(aktuell, richtung, feld));
+ return `/employees?${sp.toString()}`;
+}
+
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 sortFeld = parseFeld(params.sort);
+ const sortRichtung = parseRichtung(params.dir);
const today = todayIso();
// Comma-separated, so a dashboard tile can link here with the same
@@ -180,12 +208,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
"status",
"absence_type",
])
- // 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. Die Kennung bleibt deshalb
- // aufsteigend, auch wenn der Nachname sich dreht.
- .orderBy("last_name", richtung(sortierung))
- .orderBy("id")
+ .$call((q) => sortiere(q, sortFeld, sortRichtung))
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
).as("rows"),
@@ -228,13 +251,42 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
-
Mitarbeiter:in
-
Pers.-Nr.
-
Bereich/Team
-
Standort
-
Eintritt
-
Beschäftigung
-
Status
+ {SORTIERFELDER.map((f) => {
+ const aktiv = f.value === sortFeld;
+ return (
+ // `aria-sort` sagt einem Screenreader, welche Spalte die
+ // Reihenfolge bestimmt und in welche Richtung — der Pfeil
+ // allein ist für ihn nicht da.
+
+
+ {f.label}
+ {/* Der Pfeil steht immer da, nur blass, solange die
+ Spalte nicht sortiert: sonst springt die Kopfzeile
+ beim Klicken um eine Pfeilbreite. */}
+
+ {aktiv && sortRichtung === "desc" ? "▼" : "▲"}
+
+
+
+ );
+ })}
diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx
index a5e3059..5862256 100644
--- a/components/employees/EmployeeFilters.tsx
+++ b/components/employees/EmployeeFilters.tsx
@@ -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
))}
- {/* 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. */}
-
+ {/* 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 —
diff --git a/lib/employee-sort.ts b/lib/employee-sort.ts
index 264355a..6c55511 100644
--- a/lib/employee-sort.ts
+++ b/lib/employee-sort.ts
@@ -1,40 +1,156 @@
-// 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.
+import { sql, type Expression, type SelectQueryBuilder } from "kysely";
+import type { Schema } from "@/lib/db/schema";
-export const SORTIERUNGEN = [
- { value: "az", label: "Name A–Z" },
- { value: "za", label: "Name Z–A" },
+// Wonach die Mitarbeiterliste sortiert wird.
+//
+// Die Auswahl steht in der Adresse, 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.
+//
+// Bedient wird sie über die Spaltenköpfe. Damit das keine leere Zusage ist,
+// sortiert **jede** dieser Spalten über den gesamten Bestand, auch die drei,
+// die nicht auf der Person stehen: Bereich/Team hängt an der Planstelle,
+// Standort an einer Nachschlagetabelle, und der Status ist eine Aufzählung.
+
+export const SORTIERFELDER = [
+ { value: "name", label: "Mitarbeiter:in" },
+ { value: "persnr", label: "Pers.-Nr." },
+ { value: "bereich", label: "Bereich/Team" },
+ { value: "standort", label: "Standort" },
+ { value: "eintritt", label: "Eintritt" },
+ { value: "beschaeftigung", label: "Beschäftigung" },
+ { value: "status", label: "Status" },
] as const;
-export type Sortierung = (typeof SORTIERUNGEN)[number]["value"];
+export type Sortierfeld = (typeof SORTIERFELDER)[number]["value"];
+export type Richtung = "asc" | "desc";
-export const STANDARD_SORTIERUNG: Sortierung = "az";
+export const STANDARD_FELD: Sortierfeld = "name";
+export const STANDARD_RICHTUNG: Richtung = "asc";
+
+const FELDER: readonly string[] = SORTIERFELDER.map((f) => f.value);
/** 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;
+export function parseFeld(wert: string | undefined): Sortierfeld {
+ return FELDER.includes(wert ?? "") ? (wert as Sortierfeld) : STANDARD_FELD;
+}
+
+export function parseRichtung(wert: string | undefined): Richtung {
+ return wert === "desc" ? "desc" : STANDARD_RICHTUNG;
}
/**
- * Die Richtung für die Abfrage.
+ * Was ein Klick auf einen Spaltenkopf bewirkt.
*
- * 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.
+ * Auf der Spalte, nach der schon sortiert wird: umdrehen. Auf einer anderen:
+ * aufsteigend anfangen — nicht die Richtung der vorigen Spalte übernehmen.
+ * Wer von „Eintritt, neueste zuerst" auf „Name" wechselt, will Namen von A
+ * an, nicht von Z.
*/
-export function richtung(sortierung: Sortierung): "asc" | "desc" {
- return sortierung === "za" ? "desc" : "asc";
+export function naechsteRichtung(
+ aktuellesFeld: Sortierfeld,
+ aktuelleRichtung: Richtung,
+ geklicktesFeld: Sortierfeld
+): Richtung {
+ if (geklicktesFeld !== aktuellesFeld) return "asc";
+ return aktuelleRichtung === "asc" ? "desc" : "asc";
+}
+
+// ── Die Sortierung als SQL ──────────────────────────────────────────────
+//
+// Steht hier und nicht auf der Seite, damit sich das erzeugte SQL prüfen
+// lässt (tests/unit/employee-sort.test.ts). `sql` kommt aus Kysely und nicht
+// aus lib/db: dort steht `server-only`, und der Test liefe nicht.
+
+/** Die laufende Organisationseinheit einer Person. */
+const einheitAusdruck = sql`(
+ select ou.name
+ from position_assignments pa
+ join om_positions p on p.id = pa.position_id
+ join org_units ou on ou.id = p.org_unit_id
+ where pa.employee_id = employees.id and pa.valid_to is null
+ limit 1)`;
+
+/**
+ * Der Bereich darüber — die erste Ebene unter der Gesellschaft.
+ *
+ * Ohne Rekursion: der Baum hat höchstens vier Ebenen (Gesellschaft, Bereich,
+ * Abteilung, Team), also genügen zwei Sprünge nach oben. Sitzt jemand direkt
+ * am Bereich, greift schon der erste Fall.
+ */
+const bereichAusdruck = sql`(
+ select coalesce(
+ case when ou.unit_type = 'Bereich' then ou.name end,
+ case when e1.unit_type = 'Bereich' then e1.name end,
+ case when e2.unit_type = 'Bereich' then e2.name end)
+ from position_assignments pa
+ join om_positions p on p.id = pa.position_id
+ join org_units ou on ou.id = p.org_unit_id
+ left join org_units e1 on e1.id = ou.parent_id
+ left join org_units e2 on e2.id = e1.parent_id
+ where pa.employee_id = employees.id and pa.valid_to is null
+ limit 1)`;
+
+const standortAusdruck = sql`(select name from locations where id = employees.location_id)`;
+
+/**
+ * Hängt die Reihenfolge an eine Abfrage über `employees`.
+ *
+ * Drei der sieben Spalten stehen nicht auf der Person: Bereich und Team
+ * hängen an der Planstelle, der Standort an einer Nachschlagetabelle. Sie
+ * kommen als **korrelierte Unterabfrage**, nicht als Join.
+ *
+ * Der Grund ist die Zählung: dieselbe Filterkette liefert die Seite *und*
+ * die Gesamtzahl. Ein Join auf `position_assignments` verdoppelte jede
+ * Person mit mehr als einer Besetzung über die Zeit, und über der Liste
+ * stünde „1 203 Mitarbeiter:innen gefunden" bei 867.
+ *
+ * `valid_to is null` ist die laufende Besetzung. Wer keine hat — künftige
+ * Eintritte, Ausgetretene — bekommt hier nichts. Deshalb `nulls last`:
+ * Unbekanntes bleibt am Ende, statt beim Umdrehen der Richtung nach oben zu
+ * springen.
+ */
+export function sortiere(
+ q: SelectQueryBuilder,
+ feld: Sortierfeld,
+ richtung: Richtung
+): SelectQueryBuilder {
+ // Zwei ausgeschriebene Zweige statt einer eingesetzten Richtung: so gerät
+ // nichts aus der Adresse in die Abfrage, auch nicht als geprüfter Wert.
+ const auf = richtung === "asc";
+ const ordne = (ausdruck: Expression) =>
+ auf ? sql`${ausdruck} asc nulls last` : sql`${ausdruck} desc nulls last`;
+
+ // Nachname zuerst, dann Vorname: die Spalte zeigt „Aigner, Manuel", und
+ // unter den fünfzehn Aigner sucht niemand nach der Kennung.
+ const nachName = (b: SelectQueryBuilder) =>
+ b.orderBy(ordne(sql.ref("last_name"))).orderBy(ordne(sql.ref("first_name")));
+
+ const geordnet = (() => {
+ switch (feld) {
+ case "persnr":
+ return q.orderBy(ordne(sql.ref("personnel_number")));
+ case "bereich":
+ return nachName(q.orderBy(ordne(bereichAusdruck)).orderBy(ordne(einheitAusdruck)));
+ case "standort":
+ return nachName(q.orderBy(ordne(standortAusdruck)));
+ case "eintritt":
+ return nachName(q.orderBy(ordne(sql.ref("entry_date"))));
+ case "beschaeftigung":
+ return nachName(q.orderBy(ordne(sql.ref("employment_type"))).orderBy(ordne(sql.ref("weekly_hours"))));
+ case "status":
+ // Aufzählungstyp: Postgres ordnet nach der Reihenfolge der Werte —
+ // Aktiv, Karenz, Geplant, Ausgetreten. Das ist der Verlauf eines
+ // Dienstverhältnisses und sagt mehr als alphabetisch.
+ return nachName(q.orderBy(ordne(sql.ref("status"))));
+ case "name":
+ return nachName(q);
+ }
+ })();
+
+ // Immer zuletzt und immer aufsteigend: bei sonst gleichen Werten wäre die
+ // Reihenfolge unbestimmt, und dieselbe Person könnte auf zwei Seiten
+ // erscheinen oder auf keiner.
+ return geordnet.orderBy("id");
}
diff --git a/tests/unit/employee-sort.test.ts b/tests/unit/employee-sort.test.ts
index b05a724..ff77a2e 100644
--- a/tests/unit/employee-sort.test.ts
+++ b/tests/unit/employee-sort.test.ts
@@ -1,45 +1,230 @@
+import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely";
+import type { Schema } from "@/lib/db/schema";
import { describe, expect, it } from "vitest";
-import { SORTIERUNGEN, STANDARD_SORTIERUNG, parseSortierung, richtung } from "@/lib/employee-sort";
+import {
+ SORTIERFELDER,
+ STANDARD_FELD,
+ STANDARD_RICHTUNG,
+ naechsteRichtung,
+ parseFeld,
+ parseRichtung,
+ sortiere,
+ type Sortierfeld,
+} 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
+// Feld und Richtung stehen in der Adresse, und dort kann alles stehen: eine
+// veraltete Verknüpfung, ein Tippfehler, ein abgeschnittener Wert. Sie dürfen
// 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);
+describe("parseFeld", () => {
+ it("nimmt jedes angebotene Feld", () => {
+ // Aus der Liste abgeleitet, nicht abgeschrieben: käme eine achte Spalte
+ // dazu, prüft dieser Test sie mit, ohne dass jemand daran denken muss.
+ for (const f of SORTIERFELDER) {
+ expect(parseFeld(f.value)).toBe(f.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("fällt ohne Angabe auf den Namen zurück", () => {
+ expect(parseFeld(undefined)).toBe(STANDARD_FELD);
+ expect(parseFeld("")).toBe(STANDARD_FELD);
+ expect(STANDARD_FELD).toBe("name");
});
- 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);
+ it("lässt sich keine Spalte unterschieben", () => {
+ // Das Feld entscheidet, welcher SQL-Ausdruck in die Sortierung geht.
+ expect(parseFeld("Name")).toBe(STANDARD_FELD);
+ expect(parseFeld("sv_nummer")).toBe(STANDARD_FELD);
+ expect(parseFeld("id; drop table employees")).toBe(STANDARD_FELD);
});
});
-describe("richtung", () => {
- it("dreht nur bei Z–A", () => {
- expect(richtung("az")).toBe("asc");
- expect(richtung("za")).toBe("desc");
+describe("parseRichtung", () => {
+ it("kennt genau zwei Richtungen", () => {
+ expect(parseRichtung("asc")).toBe("asc");
+ expect(parseRichtung("desc")).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));
+ it("fällt auf aufsteigend zurück", () => {
+ expect(parseRichtung(undefined)).toBe(STANDARD_RICHTUNG);
+ expect(parseRichtung("DESC")).toBe(STANDARD_RICHTUNG);
+ expect(parseRichtung("absteigend")).toBe(STANDARD_RICHTUNG);
+ expect(STANDARD_RICHTUNG).toBe("asc");
+ });
+});
+
+describe("naechsteRichtung", () => {
+ it("dreht die Richtung, wenn dieselbe Spalte nochmal angeklickt wird", () => {
+ expect(naechsteRichtung("name", "asc", "name")).toBe("desc");
+ expect(naechsteRichtung("name", "desc", "name")).toBe("asc");
+ });
+
+ it("beginnt bei einer anderen Spalte wieder aufsteigend", () => {
+ // Wer von „Eintritt, neueste zuerst" auf „Name" wechselt, will Namen von
+ // A an — nicht die Richtung der vorigen Spalte geerbt.
+ expect(naechsteRichtung("eintritt", "desc", "name")).toBe("asc");
+ expect(naechsteRichtung("status", "desc", "persnr")).toBe("asc");
+ });
+
+ it("liefert für jede Kombination eine der beiden Richtungen", () => {
+ // Die Richtung geht unmittelbar in die Abfrage; etwas anderes als asc
+ // oder desc wäre dort ein Fehler zur Laufzeit, nicht beim Bauen.
+ const felder = SORTIERFELDER.map((f) => f.value as Sortierfeld);
+ for (const a of felder) {
+ for (const b of felder) {
+ for (const r of ["asc", "desc"] as const) {
+ expect(["asc", "desc"]).toContain(naechsteRichtung(a, r, b));
+ }
+ }
+ }
+ });
+});
+
+describe("SORTIERFELDER", () => {
+ it("deckt jede Spalte der Liste ab", () => {
+ // Die Kopfzeile wird aus dieser Liste erzeugt. Fehlt eine Spalte hier,
+ // fehlt sie in der Tabelle — und eine überzählige erzeugt einen Kopf
+ // ohne Inhalt darunter.
+ expect(SORTIERFELDER.map((f) => f.label)).toEqual([
+ "Mitarbeiter:in",
+ "Pers.-Nr.",
+ "Bereich/Team",
+ "Standort",
+ "Eintritt",
+ "Beschäftigung",
+ "Status",
+ ]);
+ });
+
+ it("vergibt jeden Wert nur einmal", () => {
+ const werte = SORTIERFELDER.map((f) => f.value);
+ expect(new Set(werte).size).toBe(werte.length);
+ });
+});
+
+// ── Das erzeugte SQL ────────────────────────────────────────────────────
+//
+// Die Sortierung ist der einzige Teil dieser Seite, bei dem ein Fehler
+// stumm bleibt: die Liste zeigt weiter fünfzehn Zeilen, nur die falschen.
+// Deshalb wird hier nicht das Verhalten beschrieben, sondern die Abfrage
+// gelesen, die tatsächlich herauskommt.
+
+const db = new Kysely({
+ dialect: {
+ createAdapter: () => new PostgresAdapter(),
+ createDriver: () => new DummyDriver(),
+ createIntrospector: (d) => new PostgresIntrospector(d),
+ createQueryCompiler: () => new PostgresQueryCompiler(),
+ },
+});
+
+const ordnung = (feld: Sortierfeld, richtung: "asc" | "desc") =>
+ sortiere(db.selectFrom("employees").select("id"), feld, richtung)
+ .compile()
+ .sql.replace(/^.*?order by /s, "")
+ .replace(/\s+/g, " ");
+
+/**
+ * Die Sortierkriterien einzeln — nur an den Kommas *ausserhalb* von Klammern
+ * getrennt.
+ *
+ * Ein einfaches `split(", ")` zerschneidet die Unterabfrage für den Bereich:
+ * sie enthält selbst Kommas, in `coalesce(a, b, c)`.
+ */
+function kriterien(orderBy: string): string[] {
+ const teile: string[] = [];
+ let tiefe = 0;
+ let laufend = "";
+ for (const c of orderBy) {
+ if (c === "(") tiefe++;
+ else if (c === ")") tiefe--;
+ if (c === "," && tiefe === 0) {
+ teile.push(laufend.trim());
+ laufend = "";
+ continue;
+ }
+ laufend += c;
+ }
+ if (laufend.trim()) teile.push(laufend.trim());
+ return teile;
+}
+
+describe("das erzeugte SQL", () => {
+ it("sortiert Namen nach Nachname, dann Vorname", () => {
+ expect(ordnung("name", "asc")).toBe(
+ '"last_name" asc nulls last, "first_name" asc nulls last, "id"'
+ );
+ });
+
+ it("dreht beide Namensteile mit", () => {
+ // Nur den Nachnamen zu drehen ergäbe Z–A über die Nachnamen, aber A–Z
+ // innerhalb einer Namensgruppe — sichtbar bei den fünfzehn Aigner.
+ expect(ordnung("name", "desc")).toBe(
+ '"last_name" desc nulls last, "first_name" desc nulls last, "id"'
+ );
+ });
+
+ it("hängt an jede Spalte die Kennung als letztes Kriterium", () => {
+ // Ohne sie ist die Reihenfolge bei gleichen Werten unbestimmt, und
+ // dieselbe Person kann auf zwei Seiten stehen oder auf keiner.
+ for (const f of SORTIERFELDER) {
+ for (const r of ["asc", "desc"] as const) {
+ expect(ordnung(f.value, r), `${f.value}/${r}`).toMatch(/, "id"$/);
+ }
+ }
+ });
+
+ it("lässt Unbekanntes immer am Ende stehen", () => {
+ // Ohne `nulls last` stünden bei absteigender Sortierung alle Personen
+ // ohne Planstelle oder Standort ganz oben.
+ for (const f of SORTIERFELDER) {
+ for (const r of ["asc", "desc"] as const) {
+ const teile = kriterien(ordnung(f.value, r)).slice(0, -1); // ohne "id"
+ expect(teile.length, `${f.value}/${r}`).toBeGreaterThan(0);
+ for (const teil of teile) {
+ expect(teil, `${f.value}/${r}: ${teil}`).toContain("nulls last");
+ }
+ }
+ }
+ });
+
+ it("holt Bereich und Team über die laufende Besetzung", () => {
+ const sql = ordnung("bereich", "asc");
+ expect(sql).toContain("position_assignments");
+ expect(sql).toContain("pa.valid_to is null");
+ // Der Bereich ist die Ebene unter der Gesellschaft, nicht die Einheit
+ // selbst — sonst stünde in der Spalte etwas anderes als sortiert wird.
+ expect(sql).toContain("unit_type = 'Bereich'");
+
+ // Und über *alle drei* Ebenen: wer in einem Team sitzt, hat den Bereich
+ // zwei Sprünge über sich. Bliebe nur die eigene Einheit übrig, stünden
+ // alle Teammitglieder ohne Bereich da und rutschten ans Listenende.
+ //
+ // Dass drei Ebenen reichen, ist eine Aussage über die Daten (der
+ // Aufzählungstyp org_unit_type kennt genau vier Stufen) und lässt sich
+ // hier nicht prüfen — dass sie überhaupt abgefragt werden, schon.
+ expect(sql).toContain("ou.unit_type = 'Bereich'");
+ expect(sql).toContain("e1.unit_type = 'Bereich'");
+ expect(sql).toContain("e2.unit_type = 'Bereich'");
+ });
+
+ it("sortiert den Standort nach seinem Namen, nicht nach seiner Kennung", () => {
+ // location_id ist eine UUID. Danach zu sortieren sähe aus wie Zufall.
+ expect(ordnung("standort", "asc")).toContain("select name from locations");
+ });
+
+ it("nimmt bei der Beschäftigung die Stunden als zweites Kriterium", () => {
+ const sql = ordnung("beschaeftigung", "asc");
+ expect(sql.indexOf("employment_type")).toBeLessThan(sql.indexOf("weekly_hours"));
+ });
+
+ it("setzt keinen Wert aus der Adresse in die Abfrage", () => {
+ // Die Richtung ist ausgeschrieben, nicht eingesetzt. Kämen je Werte aus
+ // der Adresse hierher, stünden sie im SQL statt als Parameter.
+ for (const f of SORTIERFELDER) {
+ const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc").compile();
+ expect(parameters, f.value).toEqual([]);
}
});
});