The offboarding list was a checkbox fieldset inside the exit panel — four items, never sent anywhere. Nothing in terminateEmployee's payload carried them; ticking a box there recorded exactly nothing. It's replaced with the same kind of list the entries got: its own tab, appearing the moment an exit is recorded, with one item per row, a comment on each, and — unlike the fieldset — a record of who touched it and when. The eleven items come from the same printed sheet as the entry list. Nine are plain checkboxes. Two are text fields under "Vermerke": remaining vacation and the balance transferred for payout — the sheet names "Überleitung Salden für Auszahlung" twice, once as a task to do and once as the actual figure, and those are genuinely two different questions, kept as two items. Where the sheet still says "GKK" rather than today's "ÖGK", it's left as written — that's the name the process runs under internally, not a typo. No Show gets no list. Never having worked a single day, there's no IT access to revoke, no GKK registration to undo, no Dienstzettel to collect — an empty checklist there would be a label with nothing behind it. Both the tab and the auto-creation on exit check for this specifically, not just the "Ausgetreten" status that No Show shares with a real exit. Rehiring the same person hides the tab again — the data stays, since it happened, but a checklist for someone currently working has nothing to point at. The engine (what counts as done, how progress is computed) moved into lib/checklist.ts so onboarding and offboarding can't drift into two different ideas of "done" the way two independent copies eventually do; lib/onboarding.ts and lib/offboarding.ts bind it to their own item list, and the tab UI is a single ChecklistPanel bound the same way. Checked against the real database: a real exit creates all eleven items in the same transaction as the exit itself; a No Show creates none; rehiring flips the tab off while the old answers stay queryable. One false alarm during that check turned out to be the user's own clicks on a real employee's onboarding list, made in the browser while trying the earlier feature — left untouched, not test debris. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
6.3 KiB
TypeScript
154 lines
6.3 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"),
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("offboarding_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("offboarding"),
|
|
// 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,
|
|
offboarding: g.offboarding,
|
|
openPositions,
|
|
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
|
};
|
|
}
|