Files
alpenwerk-hr/lib/dashboard-data.ts
Andrei Laas 1cbed1a8f5 Der Status kommt aus den Daten, nicht aus der Spalte
Die Liste filterte ueber die Datumsspalten, beschriftete die Zeilen aber mit
employees.status. Sobald die Spalte nachhaengt, widersprechen sich die
beiden — und sie haengt regelmaessig nach: terminate_employee setzt sie nur,
wenn das Austrittsdatum nicht in der Zukunft liegt, und es gibt keinen Lauf,
der das spaeter nachzieht (Migration 20260814100000 sagt das selbst).

Beim Kunden waren beide Richtungen zu sehen. Der Filter "Ausgetreten" fand
48 Personen, von denen mehrere als "Aktiv" beschriftet waren; der Filter
"Geplant" zeigte Nichtantritte, deren Spalte laengst "Ausgetreten" trug.

StatusChip nimmt deshalb jetzt die Zeile und den Stichtag und leitet selbst
ab. Die Spalte laesst sich nicht mehr hineinreichen — die zweite Quelle ist
nicht bloss ungenutzt, es gibt sie an dieser Stelle nicht mehr.

Dazu drei Stellen, die an derselben Spalte hingen:

  * Die Akte entschied mit ihr ueber die Knoepfe. An einer Person, die seit
    zwei Wochen ausgetreten ist, stand "Austritt" weiter zur Verfuegung.
  * Die Sortierung nach Status ordnete nach einem Wert, der nirgends auf der
    Seite steht.
  * Die Karte "Anstehend" zaehlte kuenftige Eintritte und Rueckkehren ueber
    die Spalte und damit anders als die Liste, auf die sie verlinkt.

Und eine Klausel, die in der Ableitung fehlte: ein Nichtantritt traegt als
Austrittsdatum den Eintrittstag. Liegt der in der Zukunft, ist auch der
Austritt groesser als der Stichtag — die vorige Korrektur verglich nur gegen
den Stichtag und blieb damit wirkungslos. Endet ein Verhaeltnis nicht
spaeter, als es beginnt, gab es keinen Tag Beschaeftigung, zu keinem
Stichtag.
2026-09-15 22:16:30 +02:00

179 lines
7.8 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;
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<string>().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,
};
}