Data model - employee_assignments records org placement over time (valid_from/valid_to), written by a trigger on `employees` rather than inside each RPC: ~70 `update employees` statements spread over fifteen migrations mean per-call bookkeeping would miss paths today and again with every future RPC. A partial unique index enforces the one-open-interval invariant the trigger relies on when closing the current row. - The Organigramm gains a Stichtag (default today). Membership comes from entry/exit/karenz, past placement from the new history, future placement projected from pending_org_changes. Placements predating the migration are backfilled with today's values and flagged as such in the UI, since employee_history only ever stored free text and cannot be reconstructed. Correctness - Reports and exports silently truncated at PostgREST's 1000-row cap (db.max_rows); employee_history is already past it at ~800 staff. Every whole-table read now pages explicitly. - XLSX date cells were a day early: ExcelJS converts a Date to an Excel serial straight off getTime(), so a Date built at local midnight lands on the previous day's serial in any positive-offset zone. - Date handling is pinned to Europe/Vienna throughout, and date-only strings are formatted without a Date round-trip. The dashboard's YTD window was built by round-tripping a local Date through toISOString(), which shifted it a day early and dropped 31 December entirely. - Export routes parsed measure/group/split/eventType with unchecked `as` casts, so an unknown value reached column headers as `undefined` and the Content-Disposition filename. Parsed against the label maps now, with the filename slugged as a backstop. - toXlsx keyed columns by header text, silently dropping the second of any two columns sharing a name — split columns take their header from data. - The org chart tree walks had no cycle guard; nothing in the schema forbids a manager_id cycle, and one would hang the tab rather than misreport. - The login page reflected ?error= verbatim, letting anyone put arbitrary text on the real sign-in screen; messages are looked up by code now. - React Flow needs elementsSelectable on, or it sets pointer-events:none on the whole node and the expand control stops responding. UI - Mobile: the shell was unusable below lg — a fixed 236px margin pushed content off-screen with no mobile navigation at all. The sidebar is now a drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so iOS stops zooming on focus, and form grids stack. - Org chart nodes redesigned: per-kind accent stripes and icons, vacant roles called out, expand control moved to the bottom edge carrying the child count. - Pagination is windowed; it previously rendered one link per page (54 for the employee list, unbounded for the audit log). - Positions page reduced to open positions with a single "Besetzen" action. - The employee Organisation tab links into the org chart focused on that person, reusing the chart's existing search-match highlighting. Also included, uncommitted until now - Dependants, HR notes, academic titles, split address fields, position validity and role/employment fields, with their migrations and UI. - Docker/compose deployment setup, data-model and security-review docs.
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
aggregateEvents,
|
||
aggregateReport,
|
||
deriveStatusAsOf,
|
||
groupKeyFor,
|
||
groupKeysFor,
|
||
measureValue,
|
||
MEASURE_LABELS,
|
||
type OrgLookups,
|
||
type ReportEmployee,
|
||
type ReportEvent,
|
||
} from "@/lib/reports";
|
||
|
||
function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
|
||
return {
|
||
id: "1",
|
||
first_name: "Maria",
|
||
last_name: "Gruber",
|
||
job_title: "Maschinenbediener:in",
|
||
division_id: "div-1",
|
||
team_id: "team-1",
|
||
location_id: "loc-1",
|
||
status: "Aktiv",
|
||
employment_type: "Vollzeit",
|
||
contract_type: "unbefristet",
|
||
entry_date: "2020-01-01",
|
||
exit_date: null,
|
||
weekly_hours: 38.5,
|
||
source: "Extern",
|
||
paygrade: "B",
|
||
birth_date: "1990-01-01",
|
||
gender: "w",
|
||
worker_type: "Angestellte:r",
|
||
collective_agreement: "Handel",
|
||
work_days: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||
is_betriebsrat: false,
|
||
has_dienstwagen: false,
|
||
is_laterale_fuehrung: false,
|
||
is_c_level: false,
|
||
dependents_count: 0,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const lookups: OrgLookups = {
|
||
divisionName: new Map([["div-1", "Produktion"]]),
|
||
departmentNameByTeam: new Map([["team-1", "Fertigung"]]),
|
||
teamName: new Map([["team-1", "Montage"]]),
|
||
locationName: new Map([["loc-1", "Wien-Hernals"]]),
|
||
};
|
||
|
||
describe("no salary measure in scope (consolidation §4/§8)", () => {
|
||
it("does not expose an avg_salary measure", () => {
|
||
expect(Object.keys(MEASURE_LABELS)).not.toContain("avg_salary");
|
||
});
|
||
});
|
||
|
||
describe("groupKeyFor", () => {
|
||
it("resolves division/department/team/location names via lookups", () => {
|
||
const e = emp();
|
||
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
|
||
expect(groupKeyFor(e, "department", lookups)).toBe("Fertigung");
|
||
expect(groupKeyFor(e, "team", lookups)).toBe("Montage");
|
||
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
|
||
});
|
||
|
||
it("falls back to a dash for department/team when the employee has no team", () => {
|
||
const e = emp({ team_id: null });
|
||
expect(groupKeyFor(e, "department", lookups)).toBe("–");
|
||
expect(groupKeyFor(e, "team", lookups)).toBe("–");
|
||
});
|
||
|
||
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
|
||
const e = emp({ division_id: "ghost", location_id: "ghost" });
|
||
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
|
||
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
|
||
});
|
||
|
||
it("derives entry_year from entry_date", () => {
|
||
expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019");
|
||
});
|
||
|
||
it("resolves the Rolle & Anstellung boolean dimensions as Ja/Nein", () => {
|
||
const e = emp({ is_betriebsrat: true, has_dienstwagen: false, is_laterale_fuehrung: true, is_c_level: false });
|
||
expect(groupKeyFor(e, "betriebsrat", lookups)).toBe("Ja");
|
||
expect(groupKeyFor(e, "dienstwagen", lookups)).toBe("Nein");
|
||
expect(groupKeyFor(e, "laterale_fuehrung", lookups)).toBe("Ja");
|
||
expect(groupKeyFor(e, "c_level", lookups)).toBe("Nein");
|
||
});
|
||
|
||
it("resolves worker_type/collective_agreement directly", () => {
|
||
const e = emp({ worker_type: "Arbeiter:in", collective_agreement: "Süßwaren" });
|
||
expect(groupKeyFor(e, "worker_type", lookups)).toBe("Arbeiter:in");
|
||
expect(groupKeyFor(e, "collective_agreement", lookups)).toBe("Süßwaren");
|
||
});
|
||
|
||
it("resolves has_dependents from dependents_count", () => {
|
||
expect(groupKeyFor(emp({ dependents_count: 0 }), "has_dependents", lookups)).toBe("Nein");
|
||
expect(groupKeyFor(emp({ dependents_count: 2 }), "has_dependents", lookups)).toBe("Ja");
|
||
});
|
||
});
|
||
|
||
describe("groupKeysFor", () => {
|
||
it("returns a single-element array for every ordinary dimension", () => {
|
||
expect(groupKeysFor(emp(), "division", lookups)).toEqual(["Produktion"]);
|
||
});
|
||
|
||
it("returns one key per work day for the weekday dimension", () => {
|
||
expect(groupKeysFor(emp({ work_days: ["Mo", "Mi"] }), "weekday", lookups)).toEqual(["Mo", "Mi"]);
|
||
});
|
||
});
|
||
|
||
describe("measureValue", () => {
|
||
it("returns 0 for an empty row set regardless of measure", () => {
|
||
expect(measureValue([], "headcount")).toBe(0);
|
||
});
|
||
|
||
it("counts headcount as row length", () => {
|
||
const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })];
|
||
expect(measureValue(rows, "headcount")).toBe(3);
|
||
});
|
||
|
||
it("sums FTE as weekly_hours / 38.5", () => {
|
||
const rows = [emp({ weekly_hours: 38.5 }), emp({ weekly_hours: 19.25 })];
|
||
expect(measureValue(rows, "fte")).toBeCloseTo(1.5, 5);
|
||
});
|
||
|
||
it("computes parttime_rate as a percentage of Teilzeit employees", () => {
|
||
const rows = [
|
||
emp({ employment_type: "Vollzeit" }),
|
||
emp({ employment_type: "Teilzeit" }),
|
||
emp({ employment_type: "Teilzeit" }),
|
||
emp({ employment_type: "Teilzeit" }),
|
||
];
|
||
expect(measureValue(rows, "parttime_rate")).toBe(75);
|
||
});
|
||
|
||
it("computes female_share as a percentage", () => {
|
||
const rows = [emp({ gender: "w" }), emp({ gender: "w" }), emp({ gender: "m" })];
|
||
expect(measureValue(rows, "female_share")).toBeCloseTo(66.67, 1);
|
||
});
|
||
|
||
it("computes avg_age from birth_date", () => {
|
||
const today = new Date();
|
||
const thirtyYearsAgo = `${today.getFullYear() - 30}-01-01`;
|
||
const fortyYearsAgo = `${today.getFullYear() - 40}-01-01`;
|
||
const rows = [emp({ birth_date: thirtyYearsAgo }), emp({ birth_date: fortyYearsAgo })];
|
||
expect(measureValue(rows, "avg_age")).toBeCloseTo(35, 0);
|
||
});
|
||
|
||
it("computes avg_tenure in years using exit_date when present (deterministic)", () => {
|
||
const rows = [
|
||
emp({ entry_date: "2020-01-01", exit_date: "2022-01-01" }), // ~2 years
|
||
emp({ entry_date: "2020-01-01", exit_date: "2024-01-01" }), // ~4 years
|
||
];
|
||
expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0);
|
||
});
|
||
|
||
it("computes avg_dependents as the mean dependents_count", () => {
|
||
const rows = [emp({ dependents_count: 0 }), emp({ dependents_count: 2 }), emp({ dependents_count: 4 })];
|
||
expect(measureValue(rows, "avg_dependents")).toBeCloseTo(2, 5);
|
||
});
|
||
});
|
||
|
||
describe("aggregateReport", () => {
|
||
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
|
||
const employees = [
|
||
emp({ id: "1", division_id: "div-1" }),
|
||
emp({ id: "2", division_id: "div-1" }),
|
||
emp({ id: "3", division_id: "div-2" }),
|
||
];
|
||
const multiDivisionLookups: OrgLookups = {
|
||
...lookups,
|
||
divisionName: new Map([
|
||
["div-1", "Produktion"],
|
||
["div-2", "IT"],
|
||
]),
|
||
};
|
||
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
|
||
expect(rows[0]).toMatchObject({ key: "Produktion", value: 2, count: 2 });
|
||
expect(rows[1]).toMatchObject({ key: "IT", value: 1, count: 1 });
|
||
for (const row of rows) {
|
||
for (const person of row.people) {
|
||
expect(person).not.toHaveProperty("monthly_salary_gross");
|
||
}
|
||
}
|
||
});
|
||
|
||
it("caps drilldown people list source data at the full group (UI caps display, not aggregation)", () => {
|
||
const employees = Array.from({ length: 15 }, (_, i) => emp({ id: String(i) }));
|
||
const rows = aggregateReport(employees, "headcount", "division", null, lookups);
|
||
expect(rows[0].people).toHaveLength(15);
|
||
});
|
||
|
||
it("adds a split breakdown per group when a split dimension is given", () => {
|
||
const employees = [
|
||
emp({ id: "1", employment_type: "Vollzeit" }),
|
||
emp({ id: "2", employment_type: "Teilzeit" }),
|
||
];
|
||
const rows = aggregateReport(employees, "headcount", "division", "employment_type", lookups);
|
||
expect(rows[0].split).toEqual(
|
||
expect.arrayContaining([
|
||
{ key: "Vollzeit", value: 1, count: 1 },
|
||
{ key: "Teilzeit", value: 1, count: 1 },
|
||
])
|
||
);
|
||
});
|
||
|
||
it("reconstructs headcount/avg_age/avg_tenure as of a past Stichtag, not today", () => {
|
||
// Entered 2019, exited 2021 — gone by today, but was active on 2020-06-01.
|
||
const employees = [emp({ id: "1", entry_date: "2019-01-01", exit_date: "2021-01-01", birth_date: "1990-01-01" })];
|
||
const asOf = "2020-06-01";
|
||
const rows = aggregateReport(employees, "headcount", "division", null, lookups, asOf);
|
||
expect(rows[0]).toMatchObject({ key: "Produktion", value: 1, count: 1 });
|
||
expect(measureValue(employees, "avg_age", asOf)).toBe(30);
|
||
expect(measureValue(employees, "avg_tenure", asOf)).toBeCloseTo(1.42, 1);
|
||
});
|
||
|
||
it("counts an employee once per work day for the weekday dimension, and sorts Mo→So instead of by value", () => {
|
||
const employees = [
|
||
emp({ id: "1", work_days: ["Mo", "Di", "Mi", "Do", "Fr"] }),
|
||
emp({ id: "2", work_days: ["Mo", "Mi", "Fr"] }),
|
||
];
|
||
const rows = aggregateReport(employees, "headcount", "weekday", null, lookups);
|
||
expect(rows.map((r) => r.key)).toEqual(["Mo", "Di", "Mi", "Do", "Fr"]);
|
||
expect(rows.find((r) => r.key === "Mo")).toMatchObject({ value: 2, count: 2 });
|
||
expect(rows.find((r) => r.key === "Di")).toMatchObject({ value: 1, count: 1 });
|
||
});
|
||
|
||
it("sorts a weekday split chronologically within each group", () => {
|
||
const employees = [emp({ id: "1", division_id: "div-1", work_days: ["Fr", "Mo"] })];
|
||
const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups);
|
||
expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]);
|
||
});
|
||
});
|
||
|
||
describe("deriveStatusAsOf", () => {
|
||
const base = { entry_date: "2020-01-01", exit_date: null as string | null, karenz_start_date: null as string | null, karenz_return_date: null as string | null };
|
||
|
||
it("is Geplant before the entry date", () => {
|
||
expect(deriveStatusAsOf({ ...base, entry_date: "2025-01-01" }, "2024-12-31")).toBe("Geplant");
|
||
});
|
||
|
||
it("is Ausgetreten on/after the exit date", () => {
|
||
expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-06-01")).toBe("Ausgetreten");
|
||
expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-05-31")).toBe("Aktiv");
|
||
});
|
||
|
||
it("is Karenz within the Karenz window, Aktiv once returned", () => {
|
||
const onKarenz = { ...base, karenz_start_date: "2023-01-01", karenz_return_date: "2023-07-01" };
|
||
expect(deriveStatusAsOf(onKarenz, "2023-03-01")).toBe("Karenz");
|
||
expect(deriveStatusAsOf(onKarenz, "2023-07-01")).toBe("Aktiv");
|
||
expect(deriveStatusAsOf(onKarenz, "2022-12-31")).toBe("Aktiv");
|
||
});
|
||
});
|
||
|
||
describe("aggregateEvents", () => {
|
||
const eventLookups: OrgLookups = {
|
||
...lookups,
|
||
divisionName: new Map([
|
||
["div-1", "Produktion"],
|
||
["div-2", "IT"],
|
||
]),
|
||
};
|
||
|
||
function ev(overrides: Partial<ReportEvent> = {}): ReportEvent {
|
||
return {
|
||
employee_id: "1",
|
||
first_name: "Maria",
|
||
last_name: "Gruber",
|
||
job_title: "Maschinenbediener:in",
|
||
division_id: "div-1",
|
||
team_id: "team-1",
|
||
location_id: "loc-1",
|
||
event_date: "2026-03-01",
|
||
event_type: "Eintritt",
|
||
description: "Eintritt als Maschinenbediener:in",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => {
|
||
const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", division_id: "div-2" })];
|
||
const rows = aggregateEvents(events, "event_type", null, eventLookups);
|
||
expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 });
|
||
expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 });
|
||
});
|
||
|
||
it("groups by the affected employee's org unit and preserves the event description on drill-down", () => {
|
||
const events = [ev({ division_id: "div-1" }), ev({ division_id: "div-2", employee_id: "2" })];
|
||
const rows = aggregateEvents(events, "division", null, eventLookups);
|
||
const produktion = rows.find((r) => r.key === "Produktion")!;
|
||
expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" });
|
||
});
|
||
});
|