Sort from the column headers, all seven of them
The dropdown is gone; each column header is now a link that sorts by that column, with an arrow showing the direction. Clicking the column already sorted reverses it; clicking a different one starts ascending again — going from "Eintritt, newest first" to "Name" should give you names from A, not inherit the previous direction. Names sort by surname and then forename, as asked. Both parts reverse together: turning only the surname would give Z-A across surnames but A-Z within each one, which is visible immediately among the fifteen Aigner. Three of the seven columns are not on the employee row. Bereich and Team hang off the position, Standort off a lookup table, so they are fetched as correlated subqueries rather than joins. That is not a style preference: the same filter chain produces the page *and* the count, and a join onto position_assignments would double every person who has held more than one position over time — the line above the list would read 1,203 for 867 people. Bereich is the level below the company, so it needs to walk up from the unit. No recursion: org_unit_type has exactly four levels, so two hops up cover it. Everything sorts `nulls last`, otherwise reversing the direction floats every person without a position or location to the top. The expressions live in lib/employee-sort.ts rather than in the page so the generated SQL can be read in a test — the failure mode here is silent, the list still shows fifteen rows, just the wrong ones. Eighteen tests, and the rules are mutation-checked: dropping the forename, dropping the id tiebreaker, dropping `nulls last`, sorting the location by its uuid, and shortening the Bereich walk each turn them red. Not seen in a browser: login goes through the company account and the database is unreachable. Typecheck, lint, 458 tests and the build are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,156 @@
|
||||
// Wie die Mitarbeiterliste sortiert wird.
|
||||
//
|
||||
// Wie die Filter steht auch die Sortierung in der Adresse und 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.
|
||||
//
|
||||
// Sortiert wird nach dem **Nachnamen**, weil die Liste danach gelesen wird:
|
||||
// die Spalte zeigt „Aigner, Manuel", und wer jemanden sucht, sucht ihn unter
|
||||
// A. Postgres läuft mit österreichischer Sortierregel, deshalb steht Ö bei O
|
||||
// und nicht am Ende des Alphabets.
|
||||
import { sql, type Expression, type SelectQueryBuilder } from "kysely";
|
||||
import type { Schema } from "@/lib/db/schema";
|
||||
|
||||
export const SORTIERUNGEN = [
|
||||
{ value: "az", label: "Name A–Z" },
|
||||
{ value: "za", label: "Name Z–A" },
|
||||
// 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 Sortierung = (typeof SORTIERUNGEN)[number]["value"];
|
||||
export type Sortierfeld = (typeof SORTIERFELDER)[number]["value"];
|
||||
export type Richtung = "asc" | "desc";
|
||||
|
||||
export const STANDARD_SORTIERUNG: Sortierung = "az";
|
||||
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 parseSortierung(wert: string | undefined): Sortierung {
|
||||
return SORTIERUNGEN.some((s) => s.value === wert) ? (wert as Sortierung) : STANDARD_SORTIERUNG;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Richtung für die Abfrage.
|
||||
* Was ein Klick auf einen Spaltenkopf bewirkt.
|
||||
*
|
||||
* Nur der Nachname dreht sich. Das zweite Kriterium — die Kennung — bleibt
|
||||
* aufsteigend: es entscheidet nur bei gleichem Nachnamen und dient allein
|
||||
* dazu, die Reihenfolge über die Seitengrenzen hinweg eindeutig zu halten.
|
||||
* Drehte es mit, wäre die Sortierung immer noch eindeutig, aber die vierzehn
|
||||
* Winkler stünden in umgekehrter Reihenfolge zueinander, ohne dass jemand
|
||||
* das verlangt hätte.
|
||||
* 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 richtung(sortierung: Sortierung): "asc" | "desc" {
|
||||
return sortierung === "za" ? "desc" : "asc";
|
||||
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)`;
|
||||
|
||||
/**
|
||||
* 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
|
||||
): 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":
|
||||
// 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user