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.
Positions
- Removed the "Besetzen" action, the StaffInternallyModal behind it and the
now-unreachable staffPositionInternally server action: a position is filled
through the hire process, not from the positions list. Note that
transfer_employee has no position_id at all and never touched `positions`,
so with staff_position_internally out of the UI, hire_employee is the only
thing that closes a position — a transfer into an open one leaves it open.
The RPC itself is still in the database and still covered by its tests.
SVNR
- Austrian social security numbers are now validated: ten digits, weighted
check digit mod 11, and the TTMMJJ tail cross-checked against birth_date,
which is what catches a transposed date that a valid check digit would let
through. A serial whose weighted sum lands on 11 is rejected rather than
wrapped — those are never issued.
- Applies to Austrian locations only; the German/Czech/Slovenian equivalents
have their own formats and stay free-form.
- Enforced by a trigger, not inside hire_employee/change_employee_data, for
the same reason as the assignment history: both have been redefined by
half a dozen migrations. Only a *newly written* value is checked, so a
legacy number never blocks an unrelated transfer or address change.
- The seed drew a random four-digit prefix, so its check digit was right
only by chance and every seeded Austrian row would now be rejected;
it computes the check digit properly now.
Tech stack
- next 16.2.11 closes nine advisories against 16.2.10, including a
middleware/proxy bypass in App Router apps on Turbopack — proxy.ts is this
app's entry gate. RLS remains the real boundary, so the blast radius was a
blank page rather than data, but it is a patch-level fix. Also react
19.2.8, tailwind 4.3.3, lucide-react 1.26, supabase-js/ssr, postcss.
- CI runs lint, typecheck, schema/type drift, tests and build; a second job
replays every migration onto an empty database and runs the integration
suite against it, so a migration that cannot be replayed from scratch
fails here instead of during a restore.
- scripts/check-schema-types.mjs diffs the hand-written lib/supabase/types.ts
against the migrations. Reading the SQL rather than a live database keeps
Postgres out of the fast CI job. Verified in both directions.
- vitest now runs two projects: node for logic, jsdom for components. The
first component test covers the org chart expand control, which broke
earlier this session when elementsSelectable={false} made React Flow
compute pointer-events:none for the whole node; re-introducing that prop
fails three of these tests.
- Content-Security-Policy is emitted report-only. Enforcing a policy derived
from inspection rather than from violation reports risks blanking the app;
'unsafe-inline' on script-src is required until a nonce is threaded through
proxy.ts, which is a separate change.
- Fixed supabase/seed.ts, which this session's SVNR change had broken: the
extensionless "../lib/svnr" import does not resolve under Node's ESM
loader, so the seed failed at startup.
- engines pinned to node >=22 <25, tsconfig target ES2022, and the dead
test:e2e script removed (no Playwright is installed).
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`).
Feature requests from live use:
- "Daten aendern" was missing a "Wirksam ab" field (unlike Versetzen/
Befoerdern/Karenz, which all have one) - every change was silently
logged with today's date. Added the field, threaded through
change_employee_data (defaults to today if omitted).
- Staatsbuergerschaft and Wohnland now use a searchable picker
(components/ui/CountryPicker) over the full 193-country UN member
state list (lib/countries.ts) instead of the original ~9/5-value
picklists. Dropped the now-too-narrow CHECK constraints
(supabase/schema_2.sql) since the app is the source of truth for
valid values, same approach used elsewhere for large open-ended
pickers.
Two more real bugs found via live testing of the above (both in
change_employee_data, supabase/functions.sql + functions_4.sql):
1. `text[] || 'literal'` is ambiguous in Postgres - it can resolve to
the array||array overload and try to parse the plain word as array
syntax ('{...}'), failing with "malformed array literal". Hit on
every single field-diff line the moment a user actually changed
something (Staatsbuergerschaft first, then Beschaeftigungsausmass
confirmed the same root cause). Fixed everywhere by switching to the
unambiguous array_append() function.
2. The contract_end_date diff-check cast an empty string straight to
date ("invalid input syntax for type date: ''") instead of using the
same nullif(...,'')::date guard the UPDATE line below it already had.
Verified live end-to-end after both fixes: changed Staatsbuergerschaft
to Brasilien with a backdated effective date, save succeeded, Stammdaten
tab reflects it, and employee_history got the correct event_date
("2026-07-01") and description ("Geänderte Felder: Staatsbürgerschaft,
wirksam ab 2026-07-01"). Reverted the test employee's data back
afterward; seed data is clean again.
Reports (§4.8):
- lib/reports.ts: generic server-side aggregation engine over 9 measures
(Headcount, FTE, Eintritte, Austritte, Ø Bruttogehalt, Teilzeitquote,
Ø Alter, Ø Zugehoerigkeit, Frauenanteil) x 10 group-by dimensions, with
an optional second-dimension split (disabled for average-type
measures) and per-row drill-down data.
- app/(app)/reports/page.tsx: reads filters from the URL, fetches the
matching employees_directory rows server-side, aggregates in Node
(not shipped raw to the client), computes the total.
- ReportsPageClient: measure/group/split/filter controls, 6 preset
chips, saved-reports list (actions/reports.ts), CSV export
(client-side blob download), stacked bars with a color-keyed legend
when split is active, and click-to-drill-down into the underlying
people (capped at 12, "+N weitere", linking to /employees/[id]).
Audit log (§4.9):
- app/(app)/audit/page.tsx + AuditFilters: search (target/details/actor)
+ action-type filter, paginated table with colored action badges,
row links to the affected employee when target_employee_id is set,
and the required "unveraenderbar" footer note.
This completes every route from the spec's information architecture:
Dashboard, Mitarbeiter:innen (list+detail), Organigramm (3 views),
Positionen & Bereiche, Berichte, Audit-Log, plus the Hire wizard and all
6 action panels reachable from them.
Final verification: clean npm run build + tsc --noEmit, then a full
browser walkthrough of all 6 authenticated routes as hr_admin (zero
console errors, zero 5xx responses) and a role-check pass as the
manager test account confirming action buttons and the "+ Neueinstellung"
button are hidden, and salary is masked as "... (ausgeblendet)" on the
Vertrag & Gehalt tab. Swept the database for leftover test data from
the debugging sessions above - none found, seed data is clean.
- 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.
- 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.