Der Ausdruck stieg mit zwei `left join` nach oben, weil der Baum als hoechstens vierstufig galt. `unit_type` ist aber nur ein Etikett, und die Organisation kann beliebig tief sein: bei Manner sind es sieben Ebenen. Fuer 317 der 784 Personen lag der Bereich drei oder vier Spruenge ueber der eigenen Einheit, der Ausdruck lieferte null, und diese 317 rutschten beim Sortieren nach Bereich/Team nicht unter ihre Bereiche, sondern allesamt in einen Block am Ende der Liste. Der Aufstieg haelt beim ersten Bereich an, nimmt also den naechsten und nicht den obersten -- sonst stuende bei fast allen "CEO", weil diese Einheit ueber den sechs C-Level-Bereichen liegt und selbst einer ist. Dieselbe Regel gilt in lib/reports-data.ts, das von der Wurzel absteigt und den letzten Treffer nimmt; dort gab es den Fehler nicht. Gegen den Bestand geprueft: vorher 467 von 784 mit Bereich, jetzt 784.
192 lines
8.4 KiB
TypeScript
192 lines
8.4 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 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<string>`(
|
|
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<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");
|
|
}
|