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.
Accessibility work on the UI layer, all of it rooted in one structural gap:
there were no form primitives, so every field was hand-assembled and every
field got the same details wrong.
Form primitives
- components/ui/Field.tsx (Field/TextField/SelectField/TextareaField) and
Button.tsx. Field generates the control id with useId and derives htmlFor
from it, which is what makes the association impossible to omit rather
than merely conventional.
- 92 labels existed, 4 used htmlFor, and no input carried an id at all: a
screen reader announced an unnamed edit box and clicking a label focused
nothing. Now every label resolves to its control (0 unassociated), and the
input class chain that appeared verbatim 85 times appears zero times.
- Field also takes a render prop, so Lookup, CountryPicker and Picklist get
the same wiring instead of a second, partial solution.
- SearchInput replaces three hand-rolled copies of the icon-in-a-box search
whose input had only a placeholder — not a label — and killed its own
focus ring with outline-none and nothing in its place.
- Toggle groups (workdays, reorg change type) became fieldsets with
aria-pressed; colour alone was carrying the selected state.
Comboboxes
- Lookup and CountryPicker were text inputs with a div of clickable buttons
underneath: typeable, but no keyboard path to a result and nothing telling
a screen reader a list had appeared. Both now carry role=combobox,
aria-expanded/controls/activedescendant and listbox semantics, with arrow
keys, Enter and Escape. Escape stops propagation, or it would close the
surrounding dialog along with the dropdown.
Dialogs
- useDialogFocus centralises what Modal and SlideOver each owed the
keyboard and neither provided beyond Escape: focus into the dialog on
open, Tab and Shift+Tab cycling within it, focus restored to the trigger
on close.
- SlideOver stays mounted for its transition, and aria-hidden does not
remove anything from the tab order — so every closed panel was leaving
invisible tab stops at the end of the page. `inert` fixes that.
Route states
- loading.tsx, error.tsx, not-found.tsx and global-error.tsx. Every page in
the (app) group is server-rendered per request, so without loading.tsx a
navigation showed nothing at all until the server answered, and a render
error dropped the user on Next's own screen with no way back.
Tests
- 22 component tests (vitest jsdom project). Two of them found limits of the
environment rather than of the code: jsdom implements neither `inert` nor
scrollIntoView, so the inert test asserts the attribute and the missing
scrollIntoView — which was taking the whole render down from inside an
effect — is stubbed in the setup file.
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.
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`).
- components/hire/: 4-step Hire Wizard (Person/Position/Vertrag/
Zusammenfassung) matching sec4.4, with a HireWizardProvider context so it
can be opened both from the global "+ Neueinstellung" button and from a
"Fortsetzen" link on a saved draft.
- actions/hireDrafts.ts: save/delete hire_drafts (owner-scoped RLS already
in place from Phase 1). Dashboard now shows the "Entwuerfe" card the
Phase 1 plan deferred, since the wizard it depends on now exists.
- lib/positions.ts: shared open-positions loader (position number, org
breadcrumb, resolved manager name) used by both the wizard and (later)
the Positions page.
Two real bugs found via live testing and fixed in supabase/functions.sql:
1. hire_employee/rehire_employee: a two-branch CASE returning bare string
literals defaults to `text`, not the target enum, so `status = case
when ... then 'Geplant' else 'Aktiv' end` failed against the
employment_status column. Fixed with an explicit ::employment_status
cast on the whole CASE expression.
2. Postgres precedence gotcha: ->> and || sit at the *same* precedence
tier and left-associate, so `payload->>'first_name' || ' ' ||
payload->>'last_name'` does not group the way it reads - it tries to
apply ->> to an intermediate text value and fails with "operator does
not exist: text ->> unknown". Fixed by parenthesizing every ->>'...'
expression that participates in a || chain.
Also fixed: hire_employee referenced v_position.title outside the branch
that assigns v_position, raising "record not assigned" whenever a hire
wasn't tied to a position_id; extracted a v_job_title variable instead.
Verified live end-to-end: wizard search -> select position -> submit
creates the employee, closes the position, and writes matching
employee_history + audit_log rows atomically.