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 nächste Einheit über der Person, die als Bereich * geführt wird. * * Aufgestiegen wird rekursiv, nicht mit einer festen Zahl von Sprüngen. Hier * standen zwei `left join`, weil der Baum als höchstens vierstufig galt * (Gesellschaft, Bereich, Abteilung, Team) und zwei Sprünge damit reichten. * `unit_type` ist aber nur ein Etikett, und die Organisation kann beliebig * tief sein: bei Manner sind es sieben Ebenen, und für 317 der 784 Personen * lag der Bereich drei oder vier Sprünge über der eigenen Einheit. Der * Ausdruck lieferte null, und diese 317 rutschten beim Sortieren nicht unter * ihre Bereiche, sondern allesamt in einen Block am Ende der Liste. * * Der Aufstieg hält beim ersten Bereich an, nimmt also den **nächsten** und * nicht den obersten. Ohne das Anhalten stünde bei fast allen „CEO": diese * Einheit liegt über den sechs C-Level-Bereichen und ist selbst einer. In den * Berichten gilt dieselbe Regel — lib/reports-data.ts steigt von der Wurzel * ab, dort gewinnt der letzte Treffer, und das ist derselbe Bereich. */ const bereichAusdruck = sql`( with recursive kette as ( select ou.id, ou.name, ou.unit_type, ou.parent_id, 0 as tiefe 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 union all select o.id, o.name, o.unit_type, o.parent_id, k.tiefe + 1 from kette k join org_units o on o.id = k.parent_id where k.unit_type <> 'Bereich' ) select name from kette where unit_type = 'Bereich' order by tiefe limit 1)`; const standortAusdruck = sql`(select name from locations where id = employees.location_id)`; /** * Der Status zum Stichtag als Rang — nicht die Spalte `employees.status`. * * Die Liste beschriftet jede Zeile mit dem **abgeleiteten** Status * (components/ui/StatusChip.tsx) und filtert danach * (lib/employee-status-filter.ts). Nach der gespeicherten Spalte zu sortieren * hiesse, die Zeilen nach einem Wert zu ordnen, der nirgends auf der Seite * steht: eine als „Ausgetreten" beschriftete Person landete mitten unter den * aktiven, weil in ihrer Spalte noch „Aktiv" steht. * * Die Reihenfolge ist die des Aufzählungstyps — Aktiv, Karenz, Geplant, * Ausgetreten. Das ist der Verlauf eines Dienstverhältnisses und sagt mehr * als alphabetisch. Die Klauseln stehen in derselben Reihenfolge wie in * deriveStatusAsOf; wer dort etwas ändert, ändert es auch hier. */ const statusRang = (asOf: string) => sql`case when exit_date is not null and (exit_date <= ${asOf}::date or exit_date <= entry_date) then 4 when entry_date > ${asOf}::date then 3 when karenz_start_date is not null and karenz_start_date <= ${asOf}::date and (karenz_return_date is null or karenz_return_date > ${asOf}::date) then 2 else 1 end`; /** * 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, asOf: string ): 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": return nachName(q.orderBy(ordne(statusRang(asOf)))); 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"); }