Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
The three pending migrations have been applied, so absence_type exists and
the workaround that kept it out of this select can go. The status chip in
the list shows the specific kind again ("Bildungskarenz" rather than the
generic "Langzeitabwesenheit"), matching the detail page.
Verified against the database rather than by typecheck: employee_assignments
holds 809 rows for 809 employees with exactly one open interval each,
is_valid_svnr() agrees with the TypeScript implementation on all nine
documented cases, and the list, org-chart and detail queries all return rows.
Against the hosted database a round trip costs about as much as the queries
themselves (~90ms), so page time was dominated by how many waves ran in
sequence rather than by the SQL. Measured with the median of five runs:
- Employee list 244ms -> 101ms. It awaited loadOrgMaps and only then the
page of employees; the lookup tables are needed to label rows, not to
build the query, so both now go out together.
- Employee detail 120ms -> 62ms. Nine of the ten queries key off the id
already in the URL and had no reason to wait for the employee row. The
manager comes back as an embedded resource on that row instead of a
follow-up query, which is what makes it one wave rather than two — an
intermediate version that merely reordered the waves measured *slower*,
and the embed is the part that actually helps.
- Reports 197ms -> 180ms. Three waves became two. Modest, and worth saying
so: the snapshot query itself dominates that page, not the wave count.
Also fixes a blank employee list I caused. `absence_type` was added to the
list's explicit column list ahead of its migration, and PostgREST rejects
the *entire* query for one unknown column — so `data` came back null and the
page rendered zero of 809 employees rather than just dropping a chip label.
The column is out of that select until 20260726120000_absence_type.sql is
applied; the detail page selects "*" and shows the kind once it exists.
Verified against the real database rather than by typecheck alone, which is
what would have caught it in the first place.
Karenz was doing duty as the name for every kind of extended absence, but
the cases behave differently in payroll and reporting — Wochenhilfe, a
Präsenzdienst, a long sick leave and a sabbatical are not the same thing.
The concept is now called Langzeitabwesenheit and carries which kind it is.
- employees.absence_type, constrained to the thirteen kinds. start_karenz
stores it on both paths (written straight away, or parked in the
pending_org_changes payload when the absence starts later);
record_karenz_return and the karenz_return branch of
apply_due_pending_changes clear it, so a returned employee does not keep
looking like they are still away. It also reaches employee_history, the
audit log and the employee export.
- The status enum value stays 'Karenz'. Postgres can rename an enum value in
place, but every stored function body that spells it would then reference
a value that no longer exists — a dozen functions across fifteen
migrations, rewritten for a label. The mapping lives in lib/absence.ts
instead, which is the single place the UI reads the display name from.
- Where a kind is recorded the chip shows it — "Bildungskarenz" says more
than "Langzeitabwesenheit". Absences predating the field have none and
fall back to the generic name rather than to a guess, and a value outside
the list is dropped rather than echoed into the UI.
- The export prints the display name, not the raw enum: a payroll hand-off
reading "Karenz" for what the app calls Langzeitabwesenheit only causes
questions. Audit filter options keep their stored values and change only
their labels.
- The seed spreads the twelve absences across the kinds; all of them being
Karenz would leave any breakdown by kind invisible.
Visual
- `--radius: 8px` in @theme collapsed Tailwind v4's whole radius scale onto
a single value: `rounded` and `rounded-lg` both measured 8px, so a chip, an
input and a card could not be told apart. Named steps restore the
gradation (6 / 8 / 12px, measured in the browser).
- Cards were a 1px border and nothing else. Added warm, brand-tinted
elevation tokens — a neutral black shadow over the pink surface reads as
dirt — in three steps for cards, dropdowns and overlays, collected behind
components/ui/Card.tsx so the 26 hand-copied card class chains have one
definition.
- KPI tiles lead with the number and carry a tone accent; tables got denser
rows, subtle row rules (the full border strength made 800 rows read as a
grid), tabular figures in numeric columns and a brand-tinted hover.
KPI tiles now link to the view that shows what they count. Making those
links honest surfaced two reasons the numbers did not agree with their
destinations:
- The dashboard read `employees.status`, while every report derives status
from entry/exit/karenz dates. A hire whose start date had passed before
the cron ran was counted differently on the two pages. The dashboard now
uses the same derivation — and one query instead of five.
- Eintritte/Austritte counted `entry_date`/`exit_date` while the linked
report counts `employee_history`; rehire_employee sets entry_date but logs
the event as 'Wiedereintritt', so rehires were missing from the target.
Both now count history events.
- The employee list filtered on the status column, so it disagreed too. It
now filters on derived status in SQL (lib/employee-status-filter.ts). That
restates deriveStatusAsOf a second time, in a second language, so an
integration test runs both over the full roster and requires identical id
sets — drift here is otherwise invisible.
Status semantics, per the domain correction: "aktiv" means status Aktiv
alone. Karenz is employed but not active, and has its own tile. The active
headcount, FTE (Karenz contributes no capacity) and the division bars all
follow that; the bars are labelled "Aktive nach Bereich" rather than
"Headcount" to say so. The employee filter still offers the combination,
named after the two statuses it selects instead of calling the pair active.
DEFAULT_STATUSES in lib/reports.ts is deliberately left at Aktiv + Karenz:
it governs what the Berichte page shows without an explicit status filter,
and therefore what already-saved reports and exports mean.
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.
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.
Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.
Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).
- supabase/functions.sql, functions_2.sql: Postgres RPCs for every
employee/position/reorg mutation (hire, terminate, transfer, promote,
start/adjust/return karenz, change data, rehire, create position, staff
internally, apply/undo reorg). Each resolves manager_id server-side,
writes history + audit atomically, and enforces hr_admin via
require_hr_admin() (backed by the existing RLS policy).
- actions/employees.ts, positions.ts, reorg.ts: Server Actions wrapping
the RPCs, returning success/error for client-side toast handling.
- Employees list (search/filter/pagination) and detail (4 tabs: Stammdaten,
Vertrag & Gehalt, Organisation, Historie) reading from employees_directory.
- 6 action slide-over panels: Transfer, Promote, Karenz (start/adjust/
return), Daten aendern (person+contract diffing), Terminate (with direct-
report reparenting warning + offboarding checklist), Rehire.
- lib/org.ts: shared division/department/team/location lookups.
Verified live: promote mutation updates salary, writes history/audit, and
the detail page reflects it after refresh, no console errors.
Note: the spec's Karenz-verwalten panel only covers employees already on
Karenz; added a start-Karenz mode (Karenzbeginn/geplante Rueckkehr) to
cover the Aktiv-employee case implied by the header button but not
specified in the panel list.