Files
alpenwerk-hr/lib/dashboard-data.ts
Andrei Laas fe858c6c24
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
YTD heisst bis heute, nicht bis zum Jahresende
Die Kacheln "Eintritte/Austritte (YTD)" zaehlten bis zum 31.12. und damit auch,
was erst bevorsteht: ein fuer den 01.11. erfasster Austritt stand schon im
September als geschehen da. Die Kachel sagte 2, tatsaechlich war es 1.

Der Kommentar an der Kachel nannte die Absicht seit jeher richtig -- "seit
Jahresbeginn bis heute, nicht das ganze Kalenderjahr" --, nur stand darunter
yearEnd. Was kommt, steht ohnehin in "Anstehend" daneben; die Kachel soll
sagen, was war.

Der Verweis auf den Bericht traegt denselben Zeitraum, sonst zeigte der Bericht
eine andere Zahl als die Kachel, ueber die man ihn geoeffnet hat. Und die
Variable heisst jetzt ytdBis statt yearEnd -- ein Name, der "Jahresende" sagt
und "heute" bedeutet, waere die naechste Fundstelle gewesen.
2026-09-29 20:05:55 +02:00

188 lines
8.2 KiB
TypeScript

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;
/**
* Das obere Ende des YTD-Fensters — **heute**, nicht der 31.12.
*
* Hier stand das Jahresende, womit die Kacheln „Eintritte/Austritte (YTD)"
* auch zählten, was erst bevorsteht: ein für den 01.11. erfasster Austritt
* stand schon im September als geschehen da. „Year to date" heisst bis
* heute, und die Kachel soll sagen, was war — was kommt, steht in
* „Anstehend" daneben. Gemeldet im Test vom 29.09. (B.02).
*/
ytdBis: string;
bisIso: string;
arten: AnstehendArt[];
};
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
const { userId, today, yearStart, ytdBis, 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<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", ytdBis);
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,
};
}