import type { Tx } from "./db"; import { todayIso } from "./format"; import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org"; // Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle // ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein // zweites Objekt daneben, das mit der Organisation synchron gehalten werden // müsste. export type OpenPositionResolved = { id: string; position_number: string; title: string; org_unit_id: string; is_chief: boolean; valid_from: string; valid_to: string | null; /** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */ org_unit_name: string; /** * Ob die Planstelle heute schon gilt. * * Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" — * sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides * gleich zu benennen wäre falsch. */ future: boolean; /** Wer die Stelle nach der Berichtslinie führen wird. */ managerName: string | null; orgLabel: string; /** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */ vacantSince: string; }; /** * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe * Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte. */ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null { if (!isChief) return orgUnitId; return maps.units.get(orgUnitId)?.parent_id ?? null; } export async function loadOpenPositions(tx: Tx): Promise { const asOf = todayIso(); const [orgMaps, open] = await Promise.all([ loadOrgMaps(tx), // Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die // erst beginnt.** // // Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am // 24.09. anfängt, belegt die Planstelle heute schon; sie steht nur noch // nicht besetzt da. Die frühere Fassung fragte „sitzt heute jemand // darauf?" und listete solche Stellen als offen — mit „seit 2 Tagen // unbesetzt" daneben. Aus dieser Liste speist sich auch die Auswahl im // Einstellungsassistenten, also lud sie dazu ein, dieselbe Stelle ein // zweites Mal zu besetzen. Aufgefallen wäre das erst am Teilindex der // Datenbank, nach dem Gespräch mit der zweiten Person. // // Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle // dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to. // // Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen // im Speicher. tx .selectFrom("om_positions as p") .innerJoin("jobs as j", "j.id", "p.job_id") .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"]) // Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese // Ansicht existiert: eine Stelle, die zum Quartalswechsel entsteht, // muss vorher sichtbar und planbar sein. Vorher fielen sie durch das // Raster und tauchten am Stichtag unangekündigt auf. .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)])) .where((eb) => eb.not( eb.exists( eb .selectFrom("position_assignments as a") .select("a.id") .whereRef("a.position_id", "=", "p.id") .where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) ) ) ) .orderBy("p.position_number") .execute(), ]); if (open.length === 0) return []; const positionIds = open.map((p) => p.id); // Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen // würde. const [ended, chiefs] = await Promise.all([ tx .selectFrom("position_assignments") .select(["position_id", "valid_to"]) .where("position_id", "in", positionIds) .where("valid_to", "is not", null) .execute(), (async () => { const chiefUnitIds = Array.from( new Set( open .map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)) .filter((id): id is string => Boolean(id)) ) ); if (chiefUnitIds.length === 0) return []; return tx .selectFrom("om_positions as p") .innerJoin("position_assignments as a", "a.position_id", "p.id") .innerJoin("employees as e", "e.id", "a.employee_id") .select(["p.org_unit_id", "e.first_name", "e.last_name"]) .where("p.is_chief", "=", true) .where("p.valid_to", "is", null) .where("a.valid_to", "is", null) .where("p.org_unit_id", "in", chiefUnitIds) .execute(); })(), ]); const lastEndByPosition = new Map(); for (const e of ended) { const prev = lastEndByPosition.get(e.position_id); if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to); } const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`])); return open.map((p) => { const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief); return { id: p.id, position_number: p.position_number, title: p.title, org_unit_id: p.org_unit_id, is_chief: p.is_chief, valid_from: p.valid_from, valid_to: p.valid_to, org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "", future: p.valid_from > asOf, managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from, }; }); }