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 - - - - - - - + {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. + + ); + })} 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([]); } }); });
Mitarbeiter:inPers.-Nr.Bereich/TeamStandortEintrittBeschäftigungStatus + + {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" ? "▼" : "▲"} + + +