Files
alpenwerk-hr/lib/reports-data.ts
Maximilian Stubhan 6681dcda77 Flag people who cannot simply be dismissed
Works council members, expectant mothers, parents on leave, registered
disabled employees, apprentices — each has its own rules that come
before a dismissal. The tool does not judge whether one is lawful, but
it must not stay quiet about it either, and looking it up on the
contract tab is exactly the step that gets skipped under time pressure.

So: a checkbox, an optional end date, and a red warning at the top of
the termination panel naming the date — or saying plainly that no end
was recorded. It shows for a no-show too; the protection runs from the
start of the contract, not the first day worked.

The date is optional on purpose. A works council mandate has a known
end, a pregnancy does not, and a mandatory field would force an invented
number. A constraint says only what cannot be: an end date without the
flag, which would be a leftover nobody could interpret.

The field goes the whole way through — hire, data change, contract
sheet, export, report criteria (as a yes/no and as a date range), and
the import. A field that exists in one screen and not the next is how
people stop trusting the numbers.

Terminating is now offered for planned entries as well, labelled "Nicht
angetreten", with No Show preselected. Without it a person who never
turned up stayed a planned entry forever, since nothing else can end
one.

One finding worth recording: tsc has been reporting success on a broken
program. A generated file under .next got corrupted when a build ran
against a live dev server, and its syntax errors suppressed semantic
checking everywhere else — two genuine type errors in this change went
unreported until I typechecked with .next excluded. The file is removed
and the ordinary typecheck is meaningful again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:44:00 +02:00

314 lines
12 KiB
TypeScript

import type { SelectQueryBuilder } from "kysely";
import type { Schema } from "./db/schema";
import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement";
import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
import {
deriveStatusAsOf,
EVENT_DATE_OPEN,
parseStatuses,
todayIso,
type OrgLookups,
type ReportEmployee,
type ReportEvent,
type UnitOption,
} from "./reports";
import type {
CollectiveAgreement,
ContractType,
DienstwagenArt,
EmploymentType,
GenderType,
HistoryEventType,
PaygradeType,
SourceType,
WorkerType,
} from "./supabase/types";
// Shared by the Berichte page and /api/export/* so they can never drift on
// what "the current view" means — same filters, same stichtag/event-window
// rules.
export type ReportFilters = {
/** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */
division?: string;
location?: string;
status?: string;
/** Alles Weitere — siehe lib/report-criteria.ts. */
criteria?: Criteria;
};
/**
* Macht aus den Kriterien Bedingungen auf `employees`.
*
* Bewusst Zeile für Zeile statt über eine Tabelle Schlüssel→Spalte: so prüft
* der Compiler jeden Wert gegen den Spaltentyp. Die Umwandlungen sind keine
* blinden Zusicherungen — `parseCriteria` hat den Wert vorher gegen dieselbe
* Werteliste geprüft, aus der auch der Aufzählungstyp besteht.
*
* Nicht hier: Wochentag und Angehörige (siehe passtImSpeicher), Einheit
* (Teilbaum) und Status (zum Stichtag abgeleitet) — die drei kann keine
* Spaltenbedingung ausdrücken.
*/
export function applyCriteria<O>(
q: SelectQueryBuilder<Schema, "employees", O>,
c: Criteria
): SelectQueryBuilder<Schema, "employees", O> {
const a = c.auswahl;
if (a.employment) q = q.where("employment_type", "=", a.employment as EmploymentType);
if (a.contract) q = q.where("contract_type", "=", a.contract as ContractType);
if (a.worker) q = q.where("worker_type", "=", a.worker as WorkerType);
if (a.kv) q = q.where("collective_agreement", "=", a.kv as CollectiveAgreement);
if (a.paygrade) q = q.where("paygrade", "=", a.paygrade as PaygradeType);
if (a.source) q = q.where("source", "=", a.source as SourceType);
if (a.gender) q = q.where("gender", "=", a.gender as GenderType);
if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt);
if (a.absence) q = q.where("absence_type", "=", a.absence);
const j = c.jaNein;
if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja");
if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja");
if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja");
if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja");
if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja");
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
if (c.zeitraum.entry?.von) q = q.where("entry_date", ">=", c.zeitraum.entry.von);
if (c.zeitraum.entry?.bis) q = q.where("entry_date", "<=", c.zeitraum.entry.bis);
if (c.zeitraum.exit?.von) q = q.where("exit_date", ">=", c.zeitraum.exit.von);
if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis);
if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von);
if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis);
if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von);
if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis);
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);
return q;
}
export type SnapshotFilters = ReportFilters & { asOf?: string };
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
/**
* Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und
* welches Team über ihr liegen. Ein Bericht gruppiert dann über einen
* Kartenzugriff statt über einen Aufstieg im Baum je Zeile.
*/
export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups {
const divisionName = new Map<string, string>();
const departmentName = new Map<string, string>();
const teamName = new Map<string, string>();
for (const unit of orgMaps.unitList) {
for (const a of ancestorsOf(orgMaps, unit.id)) {
if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name);
else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name);
else if (a.unit_type === "Team") teamName.set(unit.id, a.name);
}
}
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
}
export async function loadOrgLookups(tx: Tx): Promise<{
lookups: OrgLookups;
orgMaps: OrgMaps;
units: UnitOption[];
locations: { id: string; name: string }[];
}> {
const orgMaps = await loadOrgMaps(tx);
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
return {
lookups: lookupsFromOrgMaps(orgMaps, locations),
orgMaps,
// Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung
// auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen.
// Der Filter greift immer auf den ganzen Teilbaum darunter.
units: orgMaps.unitList.map((u) => ({
id: u.id,
name: u.name,
depth: orgMaps.depthOf.get(u.id) ?? 0,
unitType: u.unit_type,
})),
locations,
};
}
const SNAPSHOT_EMPLOYEE_COLUMNS = [
"id",
"first_name",
"last_name",
"job_title",
"location_id",
"employment_type",
"contract_type",
"entry_date",
"exit_date",
"weekly_hours",
"source",
"paygrade",
"birth_date",
"gender",
"karenz_start_date",
"karenz_return_date",
"worker_type",
"collective_agreement",
"work_days",
"is_betriebsrat",
"has_dienstwagen",
"is_laterale_fuehrung",
"is_c_level",
] as const;
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
// Zeilen holt und sie selbst durchgeht.
const rows = await tx
.selectFrom("employee_dependents")
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
.groupBy("employee_id")
.execute();
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
}
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
const asOf = filters.asOf || todayIso();
function snapshotQuery() {
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
}
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
snapshotQuery().execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf }),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]);
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
// deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem
// Bereich selbst nur die Bereichsleitung sitzt.
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const withDerivedStatus: ReportEmployee[] = [];
for (const e of data) {
const placement = placements.get(e.id);
// Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand,
// sitzt aber auf keiner Planstelle mehr.
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue;
withDerivedStatus.push({
id: e.id,
first_name: e.first_name,
last_name: e.last_name,
job_title: placement?.jobTitle ?? e.job_title,
org_unit_id: orgUnitId,
location_id: e.location_id,
status: deriveStatusAsOf(e, asOf),
employment_type: e.employment_type,
contract_type: e.contract_type,
entry_date: e.entry_date,
exit_date: e.exit_date,
weekly_hours: e.weekly_hours,
source: e.source,
paygrade: e.paygrade,
birth_date: e.birth_date,
gender: e.gender,
worker_type: e.worker_type,
collective_agreement: e.collective_agreement,
work_days: e.work_days,
is_betriebsrat: e.is_betriebsrat,
has_dienstwagen: e.has_dienstwagen,
is_laterale_fuehrung: e.is_laterale_fuehrung,
is_c_level: e.is_c_level,
dependents_count: dependentsCounts.get(e.id) ?? 0,
});
}
const statuses = parseStatuses(filters.status);
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
}
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte.
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem
// Team stand, in das die Person nie versetzt worden war.
//
// from/to: "" (unset) falls back to the current calendar year; the literal
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<ReportEvent[]> {
const currentYear = new Date().getFullYear();
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
function historyQuery() {
let q = tx
.selectFrom("employee_history")
.select(["employee_id", "event_date", "event_type", "description"])
.orderBy("id");
if (from) q = q.where("event_date", ">=", from);
if (to) q = q.where("event_date", "<=", to);
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
return q;
}
const [history, employees, assignments, orgMaps] = await Promise.all([
historyQuery().execute(),
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
tx
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
.orderBy("a.employee_id")
.execute(),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]);
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
for (const a of assignments) {
const list = spans.get(a.employee_id) ?? [];
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id });
spans.set(a.employee_id, list);
}
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const employeeById = new Map(employees.map((e) => [e.id, e]));
const events: ReportEvent[] = [];
for (const h of history) {
const emp = employeeById.get(h.employee_id);
if (!emp) continue;
if (filters.location && emp.location_id !== filters.location) continue;
const unitId =
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
events.push({
employee_id: emp.id,
first_name: emp.first_name,
last_name: emp.last_name,
job_title: emp.job_title,
org_unit_id: unitId,
location_id: emp.location_id,
event_date: h.event_date,
event_type: h.event_type,
description: h.description,
});
}
return events;
}