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>
146 lines
6.0 KiB
TypeScript
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])),
|
|
};
|
|
}
|