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.
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.
- Removed CLAUDE.md, AGENTS.md and NEXTJS_REBUILD_SUPERPROMPT.md, and
untracked .claude/ (now ignored locally via .git/info/exclude rather than
.gitignore, so the repo carries no reference to it either).
- The Next.js 16 warning that lived in AGENTS.md is kept where it is
actually useful, in the README tech-stack section.
- Source and migration comments referred to "the consolidation master
prompt" and "NEXTJS_REBUILD_SUPERPROMPT.md" by name; both now read "spec",
keeping the section numbers that made the cross-references worth having.
- README no longer lists Playwright, which is not installed, and now
describes the three test layers that actually exist.
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`).
Bumped React, Supabase JS, and type packages to latest patch/minor releases.
Migrated the Tailwind pipeline from the v3 JS-config format to v4's CSS-first
@theme setup (@tailwindcss/postcss, @import "tailwindcss" in globals.css),
removing tailwind.config.ts and autoprefixer since v4 handles both natively.
All existing color/radius/font tokens were ported 1:1 so no component classes
needed to change. TypeScript and ESLint were bumped and tested against their
true latest majors (7.x and 10.x) but both broke the build/lint pipeline, so
they're pinned to the latest working minor instead (TypeScript 5.9.3, ESLint
9.39.5).
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/orgchart/: 3-way segmented view sharing one server fetch
(switching tabs doesn't refetch):
- EmployeeTree: expand/collapse hierarchy from the CEO down, search with
auto-expand-to-match and highlighting, "Bereiche anzeigen" /
"Alles einklappen".
- PositionTree: models the org *structure* (GF -> Bereichsleitung ->
Abteilung -> Teamleitung -> grouped IC positions by title, expandable
to the actual holders) independent of who's currently in it, plus
dashed rows for open requisitions linking to /positions.
- ReorgWorkbench: batch multiple moves (employees / whole team / whole
department / whole division as source, always a specific team as
target), live headcount-impact table, apply via the existing
apply_reorg RPC, and an undo card wired to undo_reorg.
Bug found via live apply+undo testing: undo_reorg's cleanup DELETE on
employee_history silently matched zero rows, because that table has no
DELETE policy at all (by design, for audit immutability) - RLS filters
DELETE-eligible rows to none rather than erroring. Added
supabase/functions_3.sql: a policy scoped to hr_admin deleting only rows
that carry a reorg_scenario_id, so every other history event type stays
genuinely immutable. Verified live: apply moves an employee and updates
the headcount table correctly; undo reverts team/division/manager AND
now actually removes the Reorganisation history entries it created.
Simplification flagged here (not hidden): the spec's "Ganzes Team /
Ganze Abteilung / Ganzer Bereich" reorg moves the structural org unit
itself to a new division; this implementation resolves all four move
kinds down to individual employee moves against a specific target team,
since the schema's team->department->division chain doesn't support
freely reparenting a team object without also picking a department. The
workbench UI, headcount-impact math, and apply/undo all work correctly
under this model - only the exact "move the team as a unit" semantics
differs from the literal spec wording.
- 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.