Files
alpenwerk-hr/lib/employee-detail-data.ts
Maximilian Stubhan 82d07f0d95
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m24s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m51s
Put the onboarding checklist where the file is
The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.

Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:

  Haken   — the normal case. The Meldezettel is there or it is not.
  Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
            not decoration: "nein" is a finding, "not asked yet" is not.
            One checkbox cannot say both.
  Text    — shoe, shirt and trouser size. The value is the point;
            ticked off it would be worthless.

Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.

Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.

The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.

A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.

Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:40:02 +02:00

146 lines
6.0 KiB
TypeScript

import type { Tx } from "./db";
import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
import { jsonArrayFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format";
import { buildOrgMaps, orgMapsAbfragen } from "./org";
import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
// Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf.
//
// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der
// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion
// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war
// diese Seite damit die teuerste der Anwendung.
//
// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist
// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz
// anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts
// darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte
// durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch.
export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) {
// Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der
// Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine
// Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu
// heute gefragt lieferte om_reporting_lines() nichts, und die Akte
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
if (!employee) return null;
const asOf = orgAsOf(employee, today);
const g = await tx
.selectNoFrom((eb) => [
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// kommen aus om_reporting_lines(). Beide schränken *in* der Funktion
// ein, es wandern also neun Zeilen über die Leitung und nicht achthundert.
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"),
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"),
jsonArrayFrom(
eb
.selectFrom("employee_history")
.select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("event_date", "desc")
.orderBy("created_at", "desc")
).as("history"),
jsonArrayFrom(
eb
.selectFrom("employee_dependents")
.select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("created_at")
).as("dependents"),
jsonArrayFrom(
eb
.selectFrom("employee_notes")
.select([
"id",
"employee_id",
"author_user_id",
"author_name",
"category",
"note_text",
"due_date",
"done",
"done_by",
])
.select((x) => [
zeitstempel(x.ref("created_at")).as("created_at"),
zeitstempel(x.ref("done_at")).as("done_at"),
])
.where("employee_id", "=", id)
.orderBy("created_at", "desc")
).as("notes"),
...orgMapsAbfragen(eb),
jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"),
// Die Kontierungen der Planstellen dieser Person. Welche gilt, hängt am
// Stichtag — und der ergibt sich erst aus Eintritt und Austritt, steht
// hier also schon fest.
jsonArrayFrom(
kontierungenAbfrage(eb).where("z.position_id", "in", (e) =>
e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id)
)
).as("costRows"),
jsonArrayFrom(
eb
.selectFrom("onboarding_tasks")
.select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("employee_id", "=", id)
).as("onboarding"),
// Die offenen Planstellen bleiben bei heute: sie gehören zur
// Organisation, nicht zu dieser Person.
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
const line = g.ownLines[0] ?? null;
const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null;
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte.
const relatedIds = Array.from(
new Set(
[line?.acting_manager_id, line?.formal_manager_id, ...g.reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
);
const [relatedRows, openPositions] = await Promise.all([
relatedIds.length
? tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "job_title", "status"])
.where("id", "in", relatedIds)
.execute()
: Promise.resolve([]),
resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
]);
return {
employee,
line,
reports: g.reports,
history: g.history,
dependents: g.dependents,
notes: g.notes,
orgMaps,
placement,
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
onboarding: g.onboarding,
openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])),
};
}