Files
alpenwerk-hr/lib/employee-sort.ts
Andrei Laas 1cbed1a8f5 Der Status kommt aus den Daten, nicht aus der Spalte
Die Liste filterte ueber die Datumsspalten, beschriftete die Zeilen aber mit
employees.status. Sobald die Spalte nachhaengt, widersprechen sich die
beiden — und sie haengt regelmaessig nach: terminate_employee setzt sie nur,
wenn das Austrittsdatum nicht in der Zukunft liegt, und es gibt keinen Lauf,
der das spaeter nachzieht (Migration 20260814100000 sagt das selbst).

Beim Kunden waren beide Richtungen zu sehen. Der Filter "Ausgetreten" fand
48 Personen, von denen mehrere als "Aktiv" beschriftet waren; der Filter
"Geplant" zeigte Nichtantritte, deren Spalte laengst "Ausgetreten" trug.

StatusChip nimmt deshalb jetzt die Zeile und den Stichtag und leitet selbst
ab. Die Spalte laesst sich nicht mehr hineinreichen — die zweite Quelle ist
nicht bloss ungenutzt, es gibt sie an dieser Stelle nicht mehr.

Dazu drei Stellen, die an derselben Spalte hingen:

  * Die Akte entschied mit ihr ueber die Knoepfe. An einer Person, die seit
    zwei Wochen ausgetreten ist, stand "Austritt" weiter zur Verfuegung.
  * Die Sortierung nach Status ordnete nach einem Wert, der nirgends auf der
    Seite steht.
  * Die Karte "Anstehend" zaehlte kuenftige Eintritte und Rueckkehren ueber
    die Spalte und damit anders als die Liste, auf die sie verlinkt.

Und eine Klausel, die in der Ableitung fehlte: ein Nichtantritt traegt als
Austrittsdatum den Eintrittstag. Liegt der in der Zukunft, ist auch der
Austritt groesser als der Stichtag — die vorige Korrektur verglich nur gegen
den Stichtag und blieb damit wirkungslos. Endet ein Verhaeltnis nicht
spaeter, als es beginnt, gab es keinen Tag Beschaeftigung, zu keinem
Stichtag.
2026-09-15 22:16:30 +02:00

178 lines
7.5 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: 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<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)`;
/**
* 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 "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");
}