Org assignment history, mobile support, and a correctness pass

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.
This commit is contained in:
2026-07-24 23:38:10 +02:00
parent f96773da0f
commit 79f0e19bf8
101 changed files with 6120 additions and 700 deletions

View File

@@ -1,8 +1,11 @@
import type { EmploymentStatus, HistoryEventType } from "./supabase/types";
import { todayIso, yearsBetweenIso } from "./format";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/types";
export { todayIso };
// ── Bestand (point-in-time snapshot) ──────────────────────────────
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share";
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share" | "avg_dependents";
export type GroupDimension =
| "division"
@@ -14,7 +17,15 @@ export type GroupDimension =
| "contract_type"
| "entry_year"
| "source"
| "paygrade";
| "paygrade"
| "worker_type"
| "collective_agreement"
| "betriebsrat"
| "dienstwagen"
| "laterale_fuehrung"
| "c_level"
| "has_dependents"
| "weekday";
export const MEASURE_LABELS: Record<Measure, string> = {
headcount: "Headcount",
@@ -23,6 +34,7 @@ export const MEASURE_LABELS: Record<Measure, string> = {
avg_age: "Ø Alter",
avg_tenure: "Ø Zugehörigkeit",
female_share: "Frauenanteil",
avg_dependents: "Ø Angehörige",
};
export const GROUP_LABELS: Record<GroupDimension, string> = {
@@ -36,9 +48,17 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
entry_year: "Eintrittsjahr",
source: "Intern/Extern",
paygrade: "Paygrade",
worker_type: "Angestellte:r / Arbeiter:in",
collective_agreement: "Kollektivvertrag",
betriebsrat: "Betriebsrat",
dienstwagen: "Dienstwagen",
laterale_fuehrung: "Laterale Führung",
c_level: "C-Level",
has_dependents: "Hat Angehörige",
weekday: "Wochentag",
};
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"];
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share", "avg_dependents"];
const SUM_MEASURES: Measure[] = ["headcount", "fte"];
export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"];
@@ -74,6 +94,14 @@ export type ReportEmployee = {
paygrade: string;
birth_date: string;
gender: string;
worker_type: string;
collective_agreement: string;
work_days: Weekday[];
is_betriebsrat: boolean;
has_dienstwagen: boolean;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
dependents_count: number;
};
export type OrgLookups = {
@@ -83,10 +111,6 @@ export type OrgLookups = {
locationName: Map<string, string>;
};
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
// Reconstructs status as of any date from the columns that actually carry a
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
// which only ever reflects *today*. Division/team/location still reflect the
@@ -104,18 +128,10 @@ export function deriveStatusAsOf(
return "Aktiv";
}
function ageAsOf(birthDate: string, asOf: string): number {
const d = new Date(birthDate);
const ref = new Date(asOf);
let age = ref.getFullYear() - d.getFullYear();
if (ref.getMonth() < d.getMonth() || (ref.getMonth() === d.getMonth() && ref.getDate() < d.getDate())) age -= 1;
return age;
}
function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number {
const start = new Date(entryDate);
const end = exitDate && exitDate <= asOf ? new Date(exitDate) : new Date(asOf);
return Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
const start = Date.parse(`${entryDate}T00:00:00Z`);
const end = Date.parse(`${exitDate && exitDate <= asOf ? exitDate : asOf}T00:00:00Z`);
return Math.max(0, (end - start) / (1000 * 60 * 60 * 24 * 365.25));
}
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
@@ -135,16 +151,62 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
case "contract_type":
return e.contract_type;
case "entry_year":
return String(new Date(e.entry_date).getFullYear());
return e.entry_date.slice(0, 4);
case "source":
return e.source;
case "paygrade":
return e.paygrade;
case "worker_type":
return e.worker_type;
case "collective_agreement":
return e.collective_agreement;
case "betriebsrat":
return e.is_betriebsrat ? "Ja" : "Nein";
case "dienstwagen":
return e.has_dienstwagen ? "Ja" : "Nein";
case "laterale_fuehrung":
return e.is_laterale_fuehrung ? "Ja" : "Nein";
case "c_level":
return e.is_c_level ? "Ja" : "Nein";
case "has_dependents":
return e.dependents_count > 0 ? "Ja" : "Nein";
case "weekday":
// Not a strict partition — see groupKeysFor, which aggregateReport
// actually uses. This single-key fallback only covers a direct
// groupKeyFor("weekday", ...) call from outside aggregateReport.
return e.work_days[0] ?? "";
default:
return "Unbekannt";
}
}
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function weekdayRank(key: string): number {
const i = WEEKDAY_ORDER.indexOf(key as Weekday);
return i === -1 ? WEEKDAY_ORDER.length : i;
}
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
}
// Reused by ReportsPageClient (split legend) and the report export route
// (split columns) to render a `weekday` split chronologically rather than
// in first-encountered order; a no-op for every other dimension.
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(b)) : keys;
}
// Every dimension other than `weekday` is a strict single-key partition
// (delegates to groupKeyFor); `weekday` returns one key per work day, so an
// employee is counted in every day they work — deliberately not a
// partition, since that's the whole point of the dimension.
export function groupKeysFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string[] {
if (dim === "weekday") return e.work_days.length > 0 ? e.work_days : [""];
return [groupKeyFor(e, dim, lookups)];
}
export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number {
if (rows.length === 0) return 0;
switch (measure) {
@@ -155,11 +217,13 @@ export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: str
case "parttime_rate":
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
case "avg_age":
return rows.reduce((s, e) => s + ageAsOf(e.birth_date, asOf), 0) / rows.length;
return rows.reduce((s, e) => s + yearsBetweenIso(e.birth_date, asOf), 0) / rows.length;
case "avg_tenure":
return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length;
case "female_share":
return (rows.filter((e) => e.gender === "w").length / rows.length) * 100;
case "avg_dependents":
return rows.reduce((s, e) => s + e.dependents_count, 0) / rows.length;
default:
return 0;
}
@@ -179,9 +243,10 @@ export function aggregateReport(
): ReportRow[] {
const byGroup = new Map<string, ReportEmployee[]>();
for (const e of employees) {
const key = groupKeyFor(e, group, lookups);
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key)!.push(e);
for (const key of groupKeysFor(e, group, lookups)) {
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key)!.push(e);
}
}
const rows: ReportRow[] = [];
@@ -198,19 +263,21 @@ export function aggregateReport(
if (split) {
const bySplit = new Map<string, ReportEmployee[]>();
for (const e of rowsForGroup) {
const sKey = groupKeyFor(e, split, lookups);
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
bySplit.get(sKey)!.push(e);
for (const sKey of groupKeysFor(e, split, lookups)) {
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
bySplit.get(sKey)!.push(e);
}
}
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
const splitRows = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
key: sKey,
value: measureValue(sRows, measure, asOf),
count: sRows.length,
}));
row.split = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
}
rows.push(row);
}
return rows.sort((a, b) => b.value - a.value);
return group === "weekday" ? sortByWeekday(rows) : rows.sort((a, b) => b.value - a.value);
}
export function sumValues(rows: { value: number }[]): number {
@@ -231,6 +298,9 @@ export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimen
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" },
{ name: "Headcount nach C-Level", measure: "headcount", group: "c_level" },
{ name: "Ø Angehörige nach Bereich", measure: "avg_dependents", group: "division" },
];
// ── Ereignisse (events over a period) ─────────────────────────────
@@ -295,7 +365,7 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "event_year":
return String(new Date(e.event_date).getFullYear());
return e.event_date.slice(0, 4);
default:
return "Unbekannt";
}
@@ -347,3 +417,64 @@ export const EVENT_REPORT_PRESETS: { name: string; group: EventGroupDimension; s
{ name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" },
{ name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" },
];
// ── Query-string parsing ──────────────────────────────────────────
// The Berichte page and every /api/export/* route read the same handful of
// dimension/measure names out of a URL the user fully controls. These used
// to be unchecked `as` casts, which let an unknown value through as a real
// enum value: it reached GROUP_LABELS[group] as undefined (a literal
// "undefined" column header in the export) and was interpolated into the
// download filename, i.e. into a Content-Disposition header. Parsing against
// the label maps — the same objects that define the legal values — keeps the
// two in step by construction.
function parseKeyOf<T extends string>(labels: Record<T, string>, value: string | null | undefined, fallback: T): T {
return value && Object.hasOwn(labels, value) ? (value as T) : fallback;
}
export function parseMode(value: string | null | undefined): "snapshot" | "events" {
return value === "events" ? "events" : "snapshot";
}
export function parseMeasure(value: string | null | undefined): Measure {
return parseKeyOf(MEASURE_LABELS, value, "headcount");
}
export function parseGroupDimension(value: string | null | undefined, fallback: GroupDimension = "division"): GroupDimension {
return parseKeyOf(GROUP_LABELS, value, fallback);
}
export function parseEventGroupDimension(
value: string | null | undefined,
fallback: EventGroupDimension = "event_type"
): EventGroupDimension {
return parseKeyOf(EVENT_GROUP_LABELS, value, fallback);
}
// Unlike the dimensions above, "no split" and "all event types" are legal —
// hence null rather than a fallback value for an unrecognized input.
export function parseSplitDimension(value: string | null | undefined): GroupDimension | null {
return value && Object.hasOwn(GROUP_LABELS, value) ? (value as GroupDimension) : null;
}
export function parseEventSplitDimension(value: string | null | undefined): EventGroupDimension | null {
return value && Object.hasOwn(EVENT_GROUP_LABELS, value) ? (value as EventGroupDimension) : null;
}
export function parseEventType(value: string | null | undefined): HistoryEventType | null {
return value && Object.hasOwn(EVENT_TYPE_LABELS, value) ? (value as HistoryEventType) : null;
}
// Rejects anything that is not a real calendar date, so a Stichtag from the
// URL can never reach a date comparison (or a column header) as free text.
export function parseIsoDateParam(value: string | null | undefined): string | undefined {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const d = new Date(`${value}T00:00:00Z`);
return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value ? undefined : value;
}
// from/to additionally accept the EVENT_DATE_OPEN sentinel ("this side of
// the interval is intentionally unbounded"), which is not a date.
export function parseEventDateParam(value: string | null | undefined): string | undefined {
return value === EVENT_DATE_OPEN ? EVENT_DATE_OPEN : parseIsoDateParam(value);
}