The bell is a shared pile: every active HR person sees every open note, regardless of who wrote it. That was agreed and it stays the default — this narrows it, it never widens it. You can now untick colleagues whose notes you do not want to see. What gets stored is the *exceptions*, not the selection. The difference shows the day someone new joins HR: had the selection been stored, she would be invisible to everyone until each person ticked her, and nobody would notice her follow-ups piling up. This way she is visible from day one and hiding her is a deliberate act. Same reasoning that made notes a shared inbox in the first place — the silent gap is worse than a row too many. Own notes always come through: `note_mutes` rejects a self-reference, and the predicate says so again rather than depending on a check constraint staying put. Notes with no author come through too — hiding one because nobody knows who wrote it is exactly the loss this list exists to prevent. The rule lives in lib/notes.ts as one SQL expression because two places need it: the bell in the header and the "Anstehend" card on the dashboard. Two copies drift, and then the card counts something the bell does not show. No SQL function and no audit row, unlike anything that touches employee data — this is a personal display preference, and an audit trail recording every tick would make finding real changes harder. Same pattern as saved reports and hire drafts, and the owner policy on note_mutes means a row for someone else cannot be written even with invented values. The checkbox flips immediately and flips back if saving fails; the list gets clicked through several at a time and a round trip per tick feels like hesitation. Verified: 19 tests, five mutation-checked (or→and, dropping the own-notes clause, inverting `not exists`, inverting the default, and losing the email fallback each turn them red). Typecheck, lint, schema drift, 477 tests and the build are clean. Not seen in a browser: login goes through the company account and the database is unreachable — the migration is reviewed but has not been run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
6.5 KiB
TypeScript
165 lines
6.5 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 { 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.
|
|
|
|
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")))
|
|
// 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 ? 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,
|
|
};
|
|
}
|