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

@@ -9,7 +9,15 @@ import { currentUserId } from "@/lib/auth/session";
import { sql, withUser } from "@/lib/db"; import { sql, withUser } from "@/lib/db";
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json"; import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; 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 { derivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, fmtName, todayIso } from "@/lib/format"; import { fmtDate, fmtName, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org"; import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
@@ -24,6 +32,7 @@ type SearchParams = {
status?: string; status?: string;
location?: string; location?: string;
sort?: string; sort?: string;
dir?: string;
page?: string; page?: string;
}; };
@@ -37,17 +46,36 @@ function pageHref(params: SearchParams, page: number): string {
if (params.division) sp.set("division", params.division); if (params.division) sp.set("division", params.division);
if (params.status) sp.set("status", params.status); if (params.status) sp.set("status", params.status);
if (params.location) sp.set("location", params.location); if (params.location) sp.set("location", params.location);
// Ohne das kippte die Liste beim Blättern zurück auf A–Z, und Seite 2 // Ohne das kippte die Liste beim Blättern zurück auf den Standard, und
// zeigte Namen, die auf Seite 1 schon standen. // Seite 2 zeigte Namen, die auf Seite 1 schon standen.
if (params.sort) sp.set("sort", params.sort); if (params.sort) sp.set("sort", params.sort);
if (params.dir) sp.set("dir", params.dir);
sp.set("page", String(page)); sp.set("page", String(page));
return `/employees?${sp.toString()}`; 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) { export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
const params = await searchParams; const params = await searchParams;
const page = Math.max(1, Number(params.page ?? "1") || 1); 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(); const today = todayIso();
// Comma-separated, so a dashboard tile can link here with the same // Comma-separated, so a dashboard tile can link here with the same
@@ -180,12 +208,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
"status", "status",
"absence_type", "absence_type",
]) ])
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die .$call((q) => sortiere(q, sortFeld, sortRichtung))
// 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")
.limit(PAGE_SIZE) .limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE) .offset((page - 1) * PAGE_SIZE)
).as("rows"), ).as("rows"),
@@ -228,13 +251,42 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<table className="w-full min-w-[800px] text-sm"> <table className="w-full min-w-[800px] text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted"> <tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="px-4 py-2.5">Mitarbeiter:in</th> {SORTIERFELDER.map((f) => {
<th className="px-4 py-2.5">Pers.-Nr.</th> const aktiv = f.value === sortFeld;
<th className="px-4 py-2.5">Bereich/Team</th> return (
<th className="px-4 py-2.5">Standort</th> // `aria-sort` sagt einem Screenreader, welche Spalte die
<th className="px-4 py-2.5">Eintritt</th> // Reihenfolge bestimmt und in welche Richtung — der Pfeil
<th className="px-4 py-2.5">Beschäftigung</th> // allein ist für ihn nicht da.
<th className="px-4 py-2.5">Status</th> <th
key={f.value}
scope="col"
aria-sort={aktiv ? (sortRichtung === "asc" ? "ascending" : "descending") : "none"}
className="px-4 py-2.5"
>
<Link
href={sortHref(params, f.value, sortFeld, sortRichtung)}
// `title` nennt aus, was der Pfeil andeutet: was der
// nächste Klick tut, nicht was gerade gilt.
title={
aktiv && sortRichtung === "asc"
? `${f.label} absteigend sortieren`
: `${f.label} aufsteigend sortieren`
}
className={`group inline-flex items-center gap-1 rounded transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
aktiv ? "text-ink" : ""
}`}
>
{f.label}
{/* Der Pfeil steht immer da, nur blass, solange die
Spalte nicht sortiert: sonst springt die Kopfzeile
beim Klicken um eine Pfeilbreite. */}
<span aria-hidden className={aktiv ? "text-brand-600" : "text-ink-muted/30 group-hover:text-ink-muted"}>
{aktiv && sortRichtung === "desc" ? "▼" : "▲"}
</span>
</Link>
</th>
);
})}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -4,7 +4,6 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { SearchInput } from "@/components/ui/SearchInput"; import { SearchInput } from "@/components/ui/SearchInput";
import { SORTIERUNGEN, STANDARD_SORTIERUNG } from "@/lib/employee-sort";
type EmployeeFiltersProps = { type EmployeeFiltersProps = {
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */ /** Der ganze Baum, in Tiefensuche-Reihenfolge. */
@@ -105,23 +104,9 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
</option> </option>
))} ))}
</select> </select>
{/* Die Sortierung steht bewusst neben den Filtern und nicht in der {/* Sortiert wird nicht hier, sondern an den Spaltenköpfen der Liste.
Tabellenüberschrift: sortiert wird über den ganzen Bestand, nicht Diese Leiste schränkt ein, *was* zu sehen ist; die Reihenfolge
über die fünfzehn Zeilen dieser Seite. Ein anklickbarer Spaltenkopf gehört an die Spalte, die sie bestimmt. */}
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>
{/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist {/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest — Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —

View File

@@ -1,40 +1,156 @@
// Wie die Mitarbeiterliste sortiert wird. import { sql, type Expression, type SelectQueryBuilder } from "kysely";
// import type { Schema } from "@/lib/db/schema";
// 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 = [ // Wonach die Mitarbeiterliste sortiert wird.
{ value: "az", label: "Name A–Z" }, //
{ value: "za", label: "Name Z–A" }, // 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; ] 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. */ /** Alles, was nicht in der Liste steht, führt auf den Standard zurück. */
export function parseSortierung(wert: string | undefined): Sortierung { export function parseFeld(wert: string | undefined): Sortierfeld {
return SORTIERUNGEN.some((s) => s.value === wert) ? (wert as Sortierung) : STANDARD_SORTIERUNG; 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 * Auf der Spalte, nach der schon sortiert wird: umdrehen. Auf einer anderen:
* aufsteigend: es entscheidet nur bei gleichem Nachnamen und dient allein * aufsteigend anfangen — nicht die Richtung der vorigen Spalte übernehmen.
* dazu, die Reihenfolge über die Seitengrenzen hinweg eindeutig zu halten. * Wer von „Eintritt, neueste zuerst" auf „Name" wechselt, will Namen von A
* Drehte es mit, wäre die Sortierung immer noch eindeutig, aber die vierzehn * an, nicht von Z.
* Winkler stünden in umgekehrter Reihenfolge zueinander, ohne dass jemand
* das verlangt hätte.
*/ */
export function richtung(sortierung: Sortierung): "asc" | "desc" { export function naechsteRichtung(
return sortierung === "za" ? "desc" : "asc"; 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<string>`(
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<string>`(
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<string>`(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<O>(
q: SelectQueryBuilder<Schema, "employees", O>,
feld: Sortierfeld,
richtung: Richtung
): SelectQueryBuilder<Schema, "employees", O> {
// 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<unknown>) =>
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<Schema, "employees", O>) =>
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");
} }

View File

@@ -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 { 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 // Feld und Richtung stehen in der Adresse, und dort kann alles stehen: eine
// veraltete Verknüpfung, ein Tippfehler, ein abgeschnittener Wert. Sie darf // veraltete Verknüpfung, ein Tippfehler, ein abgeschnittener Wert. Sie dürfen
// die Liste weder leeren noch in einer Reihenfolge zeigen, die niemand // die Liste weder leeren noch in einer Reihenfolge zeigen, die niemand
// gewählt hat — und schon gar nicht in die Abfrage durchschlagen. // gewählt hat — und schon gar nicht in die Abfrage durchschlagen.
describe("parseSortierung", () => { describe("parseFeld", () => {
it("nimmt jede angebotene Sortierung", () => { it("nimmt jedes angebotene Feld", () => {
// Aus der Liste abgeleitet, nicht abgeschrieben: käme eine dritte // Aus der Liste abgeleitet, nicht abgeschrieben: käme eine achte Spalte
// Sortierung dazu, prüft dieser Test sie mit, ohne dass jemand daran // dazu, prüft dieser Test sie mit, ohne dass jemand daran denken muss.
// denken muss. for (const f of SORTIERFELDER) {
for (const s of SORTIERUNGEN) { expect(parseFeld(f.value)).toBe(f.value);
expect(parseSortierung(s.value)).toBe(s.value);
} }
}); });
it("fällt ohne Angabe auf A–Z zurück", () => { it("fällt ohne Angabe auf den Namen zurück", () => {
expect(parseSortierung(undefined)).toBe(STANDARD_SORTIERUNG); expect(parseFeld(undefined)).toBe(STANDARD_FELD);
expect(parseSortierung("")).toBe(STANDARD_SORTIERUNG); expect(parseFeld("")).toBe(STANDARD_FELD);
expect(STANDARD_SORTIERUNG).toBe("az"); expect(STANDARD_FELD).toBe("name");
}); });
it("lässt sich nichts unterschieben", () => { it("lässt sich keine Spalte unterschieben", () => {
expect(parseSortierung("AZ")).toBe(STANDARD_SORTIERUNG); // Das Feld entscheidet, welcher SQL-Ausdruck in die Sortierung geht.
expect(parseSortierung("last_name desc")).toBe(STANDARD_SORTIERUNG); expect(parseFeld("Name")).toBe(STANDARD_FELD);
expect(parseSortierung("id; drop table employees")).toBe(STANDARD_SORTIERUNG); expect(parseFeld("sv_nummer")).toBe(STANDARD_FELD);
expect(parseFeld("id; drop table employees")).toBe(STANDARD_FELD);
}); });
}); });
describe("richtung", () => { describe("parseRichtung", () => {
it("dreht nur bei Z–A", () => { it("kennt genau zwei Richtungen", () => {
expect(richtung("az")).toBe("asc"); expect(parseRichtung("asc")).toBe("asc");
expect(richtung("za")).toBe("desc"); expect(parseRichtung("desc")).toBe("desc");
}); });
it("liefert für jede Sortierung eine gültige Richtung", () => { it("fällt auf aufsteigend zurück", () => {
// Die Richtung geht unmittelbar in die Abfrage. Ein anderer Wert als expect(parseRichtung(undefined)).toBe(STANDARD_RICHTUNG);
// asc oder desc wäre dort ein Fehler zur Laufzeit, nicht beim Bauen. expect(parseRichtung("DESC")).toBe(STANDARD_RICHTUNG);
for (const s of SORTIERUNGEN) { expect(parseRichtung("absteigend")).toBe(STANDARD_RICHTUNG);
expect(["asc", "desc"]).toContain(richtung(s.value)); 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<Schema>({
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([]);
} }
}); });
}); });