Files
alpenwerk-hr/lib/dashboard-data.ts
Maximilian Stubhan 10f8f1f2d5 Keep a note on the overview until somebody ticks it off
A note with a follow-up date is a task, and the overview is where tasks
are looked for. Until now it lived only in the employee file, which is
the one place you go when you already know who you are looking for.

Follow-ups behave differently from everything else on that card, and the
difference is the point: an entry on Monday is over on Tuesday, an
unfinished task is not. So there is no lower bound on the date — what
was due and never ticked off stays, marked overdue in red, sorted to the
top because it is sorted by date. A task that drops out of the list by
itself is a forgotten task.

Only "Erledigt" removes it. A note without a follow-up date never
appears: it is a record, not a task.

Checked against the live database — an overdue one and an upcoming one
appear, one without a date and one beyond the chosen period do not, and
ticking the overdue one off removes exactly it. The probe notes were
deleted again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:44:48 +02:00

160 lines
6.2 KiB
TypeScript

import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
import { besetzungenAbfrage, pickPlacements } from "./placement";
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
import type { HistoryEventType } from "./supabase/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.
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) => [
jsonArrayFrom(
eb
.selectFrom("hire_drafts")
.select(["id", "step", "payload"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("created_by", "=", userId ?? "")
.orderBy("updated_at", "desc")
).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"])
.where("status", "=", "Geplant")
.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)
.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("status", "=", "Karenz")
.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")))
.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 ? g.drafts : [],
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,
};
}