The picker in the bell now governs both lists, so note_subscriptions is renamed to colleague_subscriptions -- a name that only mentions notes would mislead the next reader. Reading and writing a draft now reach differently far. hire_drafts_owner (for all) is split into four policies: select lets in your own drafts and those of the people you added, while insert/update/delete stay with the owner. A draft is unfinished work with no lock and no history; two people writing into the same row would overwrite each other silently. That split forces a change in the actions: a policy does not reject a write, it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row count instead of reporting success over a row that never changed. The card shows a foreign draft with its author and without Fortsetzen or Loeschen -- offering a button that reliably ends in a database error is a promise without cover. check-schema-types.mjs learns `alter table ... rename to`; without it the drift check reports one rename as two errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
7.0 KiB
TypeScript
169 lines
7.0 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 { 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"])
|
|
.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")))
|
|
// 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,
|
|
};
|
|
}
|