divisionOf nahm "die oberste Einheit unterhalb der Gesellschaft" -- eine Aussage ueber die Stellung im Baum statt ueber unit_type. Im Altmodell fiel beides zusammen, weil unter der Gesellschaft genau die Bereiche hingen. Bei Manner liegt dort allein "CEO", darunter erst die sechs C-Level-Bereiche: die Funktion gab fuer alle 784 Personen "CEO" zurueck. In der Mitarbeiterliste stand es in jeder Zeile, auf der Uebersicht lag die ganze Belegschaft im Balken "CEO", waehrend die sechs uebrigen Bereiche auf null standen. Gesucht wird jetzt der naechste Vorfahre mit unit_type = 'Bereich', die Einheit selbst eingeschlossen. Der naechste und nicht der oberste: "CEO" ist selbst ein Bereich und liegt ueber den anderen, sonst stuende er wieder ueberall. Damit meinen Liste, Uebersicht, Sortierung (lib/employee-sort.ts) und Berichte (lib/reports-data.ts) denselben Bereich -- vorher sortierte die Liste bereits richtig, zeigte aber in der Spalte etwas anderes an.
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
import type { ExpressionBuilder } from "kysely";
|
||
import type { Tx } from "./db";
|
||
import { jsonArrayFrom } from "./db/json";
|
||
import type { Schema } from "./db/schema";
|
||
import type { Database } from "./types";
|
||
|
||
/**
|
||
* Der Ausdrucksbauer einer Abfrage ohne eigene Tabelle (selectNoFrom) — das
|
||
* ist der Ort, an dem Teilabfragen zusammengehängt werden.
|
||
*/
|
||
export type OrgEb = ExpressionBuilder<Schema, never>;
|
||
|
||
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
||
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt
|
||
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte
|
||
// Ebene, ohne dass hier etwas zu ändern wäre.
|
||
|
||
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
||
|
||
export type OrgUnit = {
|
||
id: string;
|
||
org_number: string;
|
||
name: string;
|
||
parent_id: string | null;
|
||
unit_type: OrgUnitType;
|
||
};
|
||
|
||
export type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||
|
||
export type OrgMaps = {
|
||
units: Map<string, OrgUnit>;
|
||
/** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */
|
||
unitList: OrgUnit[];
|
||
/** Abstand zur Wurzel; die Wurzel selbst hat 0. */
|
||
depthOf: Map<string, number>;
|
||
childrenOf: Map<string | null, OrgUnit[]>;
|
||
locations: Map<string, Location>;
|
||
locationList: Location[];
|
||
};
|
||
|
||
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
||
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
||
/**
|
||
* Einheiten und Standorte als *Teilabfragen* — zum Einhängen in die eine
|
||
* Abfrage, die eine Seite ohnehin stellt.
|
||
*
|
||
* Fast jede Seite braucht den Baum. Als eigene Abfrage wäre das je Seitenauf-
|
||
* bau eine zusätzliche Rundreise für 65 Zeilen, die sich selten ändern; als
|
||
* Teilabfrage kostet sie nichts (lib/db/json.ts).
|
||
*/
|
||
export function orgMapsAbfragen(eb: OrgEb) {
|
||
return [
|
||
jsonArrayFrom(
|
||
eb.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number")
|
||
).as("units"),
|
||
jsonArrayFrom(eb.selectFrom("locations").selectAll().orderBy("name")).as("locations"),
|
||
] as const;
|
||
}
|
||
|
||
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
|
||
// Beides in einer Rundreise. Ein Promise.all wäre hier keine
|
||
// Gleichzeitigkeit gewesen, sondern eine Schlange — der Grund steht in
|
||
// lib/db/json.ts.
|
||
const { units, locations } = await tx
|
||
.selectNoFrom((eb) => [...orgMapsAbfragen(eb)])
|
||
.executeTakeFirstOrThrow();
|
||
|
||
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
|
||
}
|
||
|
||
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
|
||
export function buildOrgMaps(units: OrgUnit[], locations: Location[]): OrgMaps {
|
||
const childrenOf = new Map<string | null, OrgUnit[]>();
|
||
for (const u of units) {
|
||
const list = childrenOf.get(u.parent_id) ?? [];
|
||
list.push(u);
|
||
childrenOf.set(u.parent_id, list);
|
||
}
|
||
for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de"));
|
||
|
||
const unitList: OrgUnit[] = [];
|
||
const depthOf = new Map<string, number>();
|
||
const walk = (parentId: string | null, depth: number) => {
|
||
for (const u of childrenOf.get(parentId) ?? []) {
|
||
unitList.push(u);
|
||
depthOf.set(u.id, depth);
|
||
walk(u.id, depth + 1);
|
||
}
|
||
};
|
||
walk(null, 0);
|
||
|
||
return {
|
||
units: new Map(units.map((u) => [u.id, u])),
|
||
unitList,
|
||
depthOf,
|
||
childrenOf,
|
||
locations: new Map(locations.map((l) => [l.id, l])),
|
||
locationList: locations,
|
||
};
|
||
}
|
||
|
||
/** Wurzel zuerst, die Einheit selbst zuletzt. */
|
||
export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] {
|
||
const chain: OrgUnit[] = [];
|
||
const seen = new Set<string>();
|
||
let current = unitId ? maps.units.get(unitId) : undefined;
|
||
while (current && !seen.has(current.id)) {
|
||
seen.add(current.id);
|
||
chain.unshift(current);
|
||
current = current.parent_id ? maps.units.get(current.parent_id) : undefined;
|
||
}
|
||
return chain;
|
||
}
|
||
|
||
/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */
|
||
export function subtreeOf(maps: OrgMaps, unitId: string): string[] {
|
||
const out: string[] = [];
|
||
const queue = [unitId];
|
||
const seen = new Set<string>();
|
||
while (queue.length > 0) {
|
||
const id = queue.shift()!;
|
||
if (seen.has(id)) continue;
|
||
seen.add(id);
|
||
out.push(id);
|
||
for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* „Produktion › Fertigung › Montage". Die Gesellschaft bleibt weg: sie steht
|
||
* über allem und trägt in einer Zeile nichts bei.
|
||
*/
|
||
export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string {
|
||
const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft");
|
||
return chain.map((u) => u.name).join(" › ") || "–";
|
||
}
|
||
|
||
/**
|
||
* Der Bereich über einer Einheit — der nächste Vorfahre, der als Bereich
|
||
* geführt wird, die Einheit selbst eingeschlossen.
|
||
*
|
||
* Hier stand „die oberste Einheit unterhalb der Gesellschaft", also eine
|
||
* Aussage über die *Stellung* im Baum statt über das Etikett. Im Altmodell
|
||
* fiel beides zusammen: unter der Gesellschaft hingen genau die Bereiche.
|
||
* Sobald eine Einheit dazwischenliegt, fällt es auseinander — bei Manner
|
||
* hängt unter der Gesellschaft allein „CEO", und die sechs C-Level-Bereiche
|
||
* darunter. Die Funktion gab damit für alle 784 Personen „CEO" zurück: in
|
||
* der Mitarbeiterliste stand es in jeder Zeile, und auf der Übersicht lagen
|
||
* alle im Balken „CEO", während die sechs übrigen Bereiche auf null standen.
|
||
*
|
||
* Gesucht wird der **nächste** Bereich, nicht der oberste. „CEO" ist selbst
|
||
* einer und liegt über den anderen; ohne diese Wahl stünde er wieder überall.
|
||
* Dieselbe Regel gilt beim Sortieren (lib/employee-sort.ts) und in den
|
||
* Berichten (lib/reports-data.ts) — alle drei müssen denselben Bereich
|
||
* meinen, sonst sortiert die Liste nach etwas anderem, als sie anzeigt.
|
||
*/
|
||
export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
|
||
const kette = ancestorsOf(maps, unitId);
|
||
for (let i = kette.length - 1; i >= 0; i--) {
|
||
if (kette[i].unit_type === "Bereich") return kette[i];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */
|
||
export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
|
||
return unitId ? maps.units.get(unitId) : undefined;
|
||
}
|