HR reported it from testing: change someone's address and their history
shows "Geänderte Felder: Adresse, Ort" — the new address is on the
Stammdaten tab, the old one is nowhere. It was recorded, but only in the
audit log, which is a different page sorted by time and actor rather
than by person. So you had to already know what you were looking for to
find out whether an address had ever changed, let alone what it used to
be.
The field-by-field diff was being built anyway and written to the audit
log. employee_history now carries the same list, and the person's
history renders it as an expandable Feld / Vorher / Nachher table — the
same table the audit log uses, lifted into a shared component so the two
views don't drift into reading differently. It expands with <details>,
so the values are in the page: findable with Ctrl+F, present when
printed, no script involved.
The duplication with audit_log is deliberate. A person's history should
be readable on its own, including after the log is eventually thinned by
a retention rule.
Rows written before today stay without values. They could only be
reconstructed from the audit log, and the link is not reliable — no key,
only a timestamp and a person. Honestly empty beats plausibly wrong.
The migration was generated from the live function definition rather
than retyped, and the diff is four lines: two column lists, two value
lists. It carries a self-check that raises if either insert failed to
pick up the new column, and it was rehearsed inside a rolled-back
transaction against real data first — the probe confirmed the old street
name lands in the history row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
- 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.