Files
alpenwerk-hr/lib/employee-sort.ts
Andrei Laas 33d4582b30
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m17s
In der Mitarbeiterliste nur die eigene Einheit zeigen, nicht den Bereich
Die Spalte fuehrte zwei Zeilen: den Bereich und darunter die Einheit der
Person. Beim Kunden heissen die Bereiche CEO, CFO, CSMO, CPO, COO, CHRO --
Rollenbezeichnungen, die ueber jedem Namen dasselbe wiederholten. Auf
seinen Wunsch bleibt nur die Einheit stehen; der ganze Weg von oben steht
weiterhin im title, denn eine Einheit wie "Shopleitung" sagt allein nicht,
welche gemeint ist.

Sortiert wird jetzt ebenfalls nach der Einheit. Bliebe der Bereich das
erste Kriterium, ordnete die Spalte nach einem Wert, den sie nicht mehr
anzeigt -- von aussen sieht das aus wie gar keine Sortierung. Der
rekursive Ausdruck aus 3e49be5 entfaellt damit; divisionOf bleibt in
Gebrauch, die Uebersicht gruppiert weiter nach Bereich.
2026-09-22 16:52:23 +02:00

167 lines
7.2 KiB
TypeScript

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: die Organisationseinheit hängt an der
// Planstelle, der 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: "einheit", label: "Organisationseinheit" },
{ 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<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)`;
// Hier stand ein zweiter Ausdruck, der den Bereich über der Person suchte:
// die Liste sortierte nach Bereich und erst darin nach der Einheit. Der
// Bereich steht seit 22.09.2026 nicht mehr in der Spalte — der Kunde wollte
// dort nur die eigene Einheit sehen, weil die Bereiche bei ihm CEO, CFO, COO
// heissen und als Zeile über jedem Namen nichts beitragen. Sortiert wird
// seither nach dem, was auch dasteht. Eine Sortierung nach einem Wert, den
// die Spalte nicht zeigt, sieht von aussen aus wie gar keine Sortierung.
const standortAusdruck = sql<string>`(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<number>`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<O>(
q: SelectQueryBuilder<Schema, "employees", O>,
feld: Sortierfeld,
richtung: Richtung,
asOf: string
): 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 "einheit":
return nachName(q.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");
}