import { sql, type Expression, type SelectQueryBuilder } from "kysely"; import type { Schema } from "@/lib/db/schema"; // 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 Sortierfeld = (typeof SORTIERFELDER)[number]["value"]; export type Richtung = "asc" | "desc"; 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 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; } /** * Was ein Klick auf einen Spaltenkopf bewirkt. * * 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 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"); }