import type { Tx } from "./db"; import { jsonArrayFrom, jsonObjectFrom } from "./db/json"; import { besetzungenAbfrage, pickPlacements } from "./placement"; import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org"; import { baueEntwuerfe, entwuerfeAbfrage, type EntwurfZeile } from "./entwuerfe"; import { derivedStatusFilter } from "./employee-status-filter"; import { sichtbareNotizen } from "./notes"; import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; import type { HistoryEventType } from "./types"; import type { AnstehendArt } from "./dashboard-filter"; // Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn. // // Hier und nicht in der Seite, damit sich die Zahl der Rundreisen messen und // das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente // aufzubauen. /** Eine gültige uuid, die keiner Zeile gehört — für den abgemeldeten Fall. */ const KEINE_KENNUNG = "00000000-0000-0000-0000-000000000000"; export type DashboardParams = { userId: string | null; today: string; yearStart: string; yearEnd: string; bisIso: string; arten: AnstehendArt[]; }; export async function loadDashboardData(tx: Tx, p: DashboardParams) { const { userId, today, yearStart, yearEnd, bisIso, arten } = p; const zeigt = (art: AnstehendArt) => arten.includes(art); // Elf Abfragen standen hier in einem Promise.all, das keine // Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und // über eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms // Umlaufzeit war das eine knappe Sekunde Warten für Daten, die zusammen // keine 200 kB wiegen. Jetzt: eine Rundreise, und eine zweite für die // offenen Stellen, deren zweite Hälfte vom Ergebnis der ersten abhängt. // Der Weg dahin steht in lib/db/json.ts. const countIn = (eb: OrgEb, types: readonly HistoryEventType[]) => eb .selectFrom("employee_history") .select(({ fn }) => fn.countAll().as("anzahl")) .where("event_type", "in", [...types]) .where("event_date", ">=", yearStart) .where("event_date", "<=", yearEnd); const g = await tx .selectNoFrom((eb) => [ // Die eigenen Entwürfe und die der hinzugewählten Kolleg:innen. // // Ohne Anmeldung geht eine Kennung mit, die es nicht gibt: die Spalte // ist vom Typ uuid, und der leere Text, der hier stand, hätte sich // nicht dorthin umwandeln lassen — die Abfrage wäre nicht leer // ausgegangen, sondern gescheitert. Das Ergebnis wird unten ohnehin // verworfen. jsonArrayFrom(entwuerfeAbfrage(eb, userId ?? KEINE_KENNUNG)).as("drafts"), jsonArrayFrom( eb .selectFrom("employees") .select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"]) .orderBy("id") ).as("staffRows"), // Entries/exits count history events, which is what the linked report // counts too. `entry_date` would also sweep up rehires, whose event is // logged as 'Wiedereintritt' — the tile and its destination then showed // different numbers for the same year. jsonObjectFrom(countIn(eb, ["Eintritt", "Wiedereintritt"])).as("hiresYtd"), jsonObjectFrom(countIn(eb, ["Austritt"])).as("exitsYtd"), ...orgMapsAbfragen(eb), jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), jsonArrayFrom(besetzungenAbfrage(eb)).as("placementRows"), // Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie // ohnehin nicht, und eine Teilabfrage, deren Ergebnis niemand ansieht, // ist eine Teilabfrage zu viel. jsonArrayFrom( eb .selectFrom("employees") .select(["id", "first_name", "last_name", "entry_date"]) // Abgeleitet, nicht über die Spalte `status`: ein Nichtantritt // trägt dort sofort „Ausgetreten", ein erfasster künftiger Austritt // dagegen weiter „Aktiv". Beides liess die Karte „Anstehend" etwas // anderes zählen als die Liste, auf die sie verlinkt. .where((e) => derivedStatusFilter(e, ["Geplant"], today) ?? e.val(true)) .where("entry_date", ">=", today) .where("entry_date", "<=", bisIso) .where((e) => e.lit(zeigt("hire"))) ).as("upcomingHires"), jsonArrayFrom( eb .selectFrom("employees") .select(["id", "first_name", "last_name", "exit_date"]) .where("exit_date", "is not", null) // Ein Nichtantritt trägt als Austrittsdatum den Eintrittstag. Liegt // der in der Zukunft, stand er hier als bevorstehender Austritt — // vorzubereiten gibt es daran nichts, die Person fängt gar nicht an. // Dieselbe Überlegung wie bei gehoertOffboarding(). .where((e) => e(e.ref("exit_date"), ">", e.ref("entry_date"))) .where("exit_date", ">=", today) .where("exit_date", "<=", bisIso) .where((e) => e.lit(zeigt("exit"))) ).as("upcomingExits"), jsonArrayFrom( eb .selectFrom("employees") .select(["id", "first_name", "last_name", "karenz_return_date"]) .where((e) => derivedStatusFilter(e, ["Karenz"], today) ?? e.val(true)) .where("karenz_return_date", "is not", null) .where("karenz_return_date", ">=", today) .where("karenz_return_date", "<=", bisIso) .where((e) => e.lit(zeigt("return"))) ).as("upcomingReturns"), // Wiedervorlagen. Sie verhalten sich anders als der Rest dieser Karte: // ein Eintritt am Montag ist am Dienstag vorbei, eine Wiedervorlage // nicht. Deshalb gibt es hier **keine untere Grenze** — was fällig war // und nicht abgehakt wurde, bleibt stehen, bis jemand „Erledigt" klickt. // Eine Aufgabe, die von selbst aus der Liste rutscht, ist eine // vergessene Aufgabe. jsonArrayFrom( eb .selectFrom("employee_notes as n") .innerJoin("employees as e", "e.id", "n.employee_id") .select(["n.id", "n.employee_id", "n.due_date", "n.category", "n.note_text", "e.first_name", "e.last_name"]) .where("n.done", "=", false) .where("n.due_date", "is not", null) .where("n.due_date", "<=", bisIso) .where((x) => x.lit(zeigt("note"))) // Dieselbe Regel wie in der Glocke (lib/notes.ts). Ohne sie zeigte // die Karte „Anstehend" Wiedervorlagen von Kolleg:innen, die in der // Glocke abgewählt sind — zwei Zahlen für dieselbe Frage. .where(sichtbareNotizen(userId ?? "")) .orderBy("n.due_date") ).as("upcomingNotes"), jsonArrayFrom( eb .selectFrom("employee_history as h") .leftJoin("employees as e", "e.id", "h.employee_id") .select([ "h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name", ]) .orderBy("h.event_date", "desc") .orderBy("h.created_at", "desc") .limit(10) ).as("history"), ]) .executeTakeFirstOrThrow(); const orgMaps = buildOrgMaps(g.units as never, g.locations as never); return { drafts: userId ? baueEntwuerfe(g.drafts as EntwurfZeile[], userId) : [], staffRows: g.staffRows, hiresYtd: Number(g.hiresYtd?.anzahl ?? 0), exitsYtd: Number(g.exitsYtd?.anzahl ?? 0), openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today), orgMaps, placements: pickPlacements(g.placementRows as never, today), upcomingHires: g.upcomingHires, upcomingExits: g.upcomingExits, upcomingReturns: g.upcomingReturns, upcomingNotes: g.upcomingNotes, history: g.history, }; }