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

212
lib/orgchart-data.ts Normal file
View File

@@ -0,0 +1,212 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { OrgEmployee } from "@/components/orgchart/types";
import { todayIso } from "./format";
import { deriveStatusAsOf } from "./reports";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// The Organigramm as it stood (or will stand) on a given date. Three sources
// have to be reconciled, because no single one covers the whole timeline:
//
// past/today employee_assignments — the interval covering `asOf`
// future pending_org_changes — effective-dated moves not yet applied
// membership entry/exit/karenz — who counted as staff on that date
//
// See supabase/migrations/*_employee_assignment_history.sql for why the
// placement timeline is captured by a trigger rather than per-RPC.
const ORG_COLUMNS =
"id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date";
/** Change types that move someone in the org; the rest only affect status or contract. */
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
export type OrgAsOfResult = {
employees: OrgEmployee[];
/** How many placements were projected from not-yet-applied changes. */
projectedCount: number;
/** Earliest date the assignment history actually covers. */
historyStartsAt: string | null;
};
type EmployeeRow = {
id: string;
personnel_number: number;
first_name: string;
last_name: string;
job_title: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
is_lead: boolean;
org_level: number;
entry_date: string;
exit_date: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
};
type AssignmentRow = {
employee_id: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
job_title: string;
is_lead: boolean;
org_level: number;
valid_from: string;
};
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number };
// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to
// their team's lead, a team lead to the division head, and anyone without a
// team to the CEO. Only used for employees a pending change actually moves —
// everyone else keeps the manager recorded on their assignment, so existing
// data that deviates from the rule is never silently "corrected".
function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null {
if (placement.team_id && !placement.is_lead) {
return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null;
}
if (placement.team_id && placement.is_lead) {
return (
all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ??
null
);
}
return all.find((e) => e.placement.org_level === 0)?.id ?? null;
}
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
const today = todayIso();
const [allEmployees, assignments, teams, departments, pending] = await Promise.all([
fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")),
fetchAllRows(() =>
supabase
.from("employee_assignments")
.select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("employee_id")
),
fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")),
fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")),
asOf > today
? fetchAllRows(() =>
supabase
.from("pending_org_changes")
.select("employee_id, change_type, effective_date, payload")
.eq("status", "pending")
.lte("effective_date", asOf)
.in("change_type", [...PLACEMENT_CHANGES])
.order("effective_date")
)
: Promise.resolve([]),
]);
return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
}
// The pure half of the above: everything that turns the four row sets into a
// snapshot, with no Supabase client in sight, so the reconciliation rules can
// be tested directly.
export function resolveOrgSnapshot({
asOf,
employees: allEmployees,
assignments,
teams,
departments,
pending,
}: {
asOf: string;
employees: EmployeeRow[];
assignments: AssignmentRow[];
teams: { id: string; department_id: string }[];
departments: { id: string; division_id: string }[];
pending: PendingRow[];
}): OrgAsOfResult {
const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a]));
// A projected move names only the target team; its division follows from
// the team's department, the same way the DB trigger derives it.
const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id]));
const teamDivision = new Map(
teams.flatMap((t) => {
const divisionId = departmentDivision.get(t.department_id);
return divisionId ? [[t.id, divisionId] as const] : [];
})
);
// Employed (or on leave) on that date — the same derivation the Berichte
// page uses, so the two can never disagree on who counted when.
const staff = allEmployees.filter((e) => {
const status = deriveStatusAsOf(e, asOf);
return status === "Aktiv" || status === "Karenz";
});
const resolved = staff.map((e) => {
const a = assignmentByEmployee.get(e.id);
return {
employee: e,
managerId: a ? a.manager_id : e.manager_id,
placement: {
team_id: a ? a.team_id : e.team_id,
division_id: a ? a.division_id : e.division_id,
job_title: a ? a.job_title : e.job_title,
is_lead: a ? a.is_lead : e.is_lead,
org_level: a ? a.org_level : e.org_level,
} satisfies Placement,
};
});
// Project the future. Ordered by effective_date, so a later move wins.
const byId = new Map(resolved.map((r) => [r.employee.id, r]));
const moved = new Set<string>();
for (const change of pending) {
const target = byId.get(change.employee_id);
if (!target) continue;
const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string };
const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null;
if (newTeamId) {
target.placement.team_id = newTeamId;
const divisionId = teamDivision.get(newTeamId);
if (divisionId) target.placement.division_id = divisionId;
moved.add(target.employee.id);
}
if (payload.new_title) target.placement.job_title = payload.new_title;
}
// Second pass: a moved employee's manager follows from the *projected*
// org, not the one they left — and the lead of their new team may itself
// have moved in this same batch.
for (const r of resolved) {
if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement })));
}
// A manager who had not joined yet, or had already left, is not in this
// set — without re-rooting, their whole reporting line would silently
// vanish from the chart rather than showing up one level higher.
const presentIds = new Set(resolved.map((r) => r.employee.id));
const employees: OrgEmployee[] = resolved.map((r) => ({
id: r.employee.id,
personnel_number: r.employee.personnel_number,
first_name: r.employee.first_name,
last_name: r.employee.last_name,
job_title: r.placement.job_title,
manager_id: r.managerId && presentIds.has(r.managerId) ? r.managerId : null,
team_id: r.placement.team_id,
division_id: r.placement.division_id,
is_lead: r.placement.is_lead,
org_level: r.placement.org_level,
}));
const historyStartsAt = assignments.reduce<string | null>(
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
null
);
return { employees, projectedCount: moved.size, historyStartsAt };
}