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; // 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; /** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */ unitList: OrgUnit[]; /** Abstand zur Wurzel; die Wurzel selbst hat 0. */ depthOf: Map; childrenOf: Map; locations: Map; 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 { // 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(); 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(); 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(); 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(); 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; }