import type { Tx } from "./db"; import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers"; import { jsonArrayFrom, zeitstempel } from "./db/json"; import { todayIso } from "./format"; import { buildOrgMaps, orgMapsAbfragen } from "./org"; import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement"; import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; // Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf. // // Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der // Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion // hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war // diese Seite damit die teuerste der Anwendung. // // Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist // keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz // anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts // darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte // durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch. export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) { // Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der // Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine // Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu // heute gefragt lieferte om_reporting_lines() nichts, und die Akte // behauptete „Keine Führungskraft", obwohl das Team eine hat. const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(); if (!employee) return null; const asOf = orgAsOf(employee, today); const g = await tx .selectNoFrom((eb) => [ // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie // kommen aus om_reporting_lines(). Beide schränken *in* der Funktion // ein, es wandern also neun Zeilen über die Leitung und nicht achthundert. jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"), jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"), jsonArrayFrom( eb .selectFrom("employee_history") .select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"]) .select((x) => zeitstempel(x.ref("created_at")).as("created_at")) .where("employee_id", "=", id) .orderBy("event_date", "desc") .orderBy("created_at", "desc") ).as("history"), jsonArrayFrom( eb .selectFrom("employee_dependents") .select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"]) .select((x) => zeitstempel(x.ref("created_at")).as("created_at")) .where("employee_id", "=", id) .orderBy("created_at") ).as("dependents"), jsonArrayFrom( eb .selectFrom("employee_notes") .select([ "id", "employee_id", "author_user_id", "author_name", "category", "note_text", "due_date", "done", "done_by", ]) .select((x) => [ zeitstempel(x.ref("created_at")).as("created_at"), zeitstempel(x.ref("done_at")).as("done_at"), ]) .where("employee_id", "=", id) .orderBy("created_at", "desc") ).as("notes"), ...orgMapsAbfragen(eb), jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"), // Die Kontierungen der Planstellen dieser Person. Welche gilt, hängt am // Stichtag — und der ergibt sich erst aus Eintritt und Austritt, steht // hier also schon fest. jsonArrayFrom( kontierungenAbfrage(eb).where("z.position_id", "in", (e) => e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id) ) ).as("costRows"), // Die offenen Planstellen bleiben bei heute: sie gehören zur // Organisation, nicht zu dieser Person. jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), ]) .executeTakeFirstOrThrow(); const orgMaps = buildOrgMaps(g.units as never, g.locations as never); const line = g.ownLines[0] ?? null; const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null; // Namen für die beteiligten Personen in einem Zug: die Vertretung, die // formal zuständige Leitung und die direkten Berichte. const relatedIds = Array.from( new Set( [line?.acting_manager_id, line?.formal_manager_id, ...g.reports.map((r) => r.employee_id)].filter( (x): x is string => Boolean(x) ) ) ); const [relatedRows, openPositions] = await Promise.all([ relatedIds.length ? tx .selectFrom("employees") .select(["id", "first_name", "last_name", "job_title", "status"]) .where("id", "in", relatedIds) .execute() : Promise.resolve([]), resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today), ]); return { employee, line, reports: g.reports, history: g.history, dependents: g.dependents, notes: g.notes, orgMaps, placement, // Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt. kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null, openPositions, byId: new Map(relatedRows.map((e) => [e.id, e])), }; }