4 Commits

Author SHA1 Message Date
37bb107cd4 Visual pass, clickable KPI tiles, and one consistent definition of status
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.
2026-07-25 13:39:49 +02:00
d9367a8ce4 Form primitives, keyboard-operable comboboxes, dialog focus, route states
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.
2026-07-25 13:11:09 +02:00
901c5c426e Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
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`).
2026-07-14 20:32:20 +02:00
131ca7ece7 Daten aendern: effective date, searchable UN country pickers, 2 more bugfixes
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.
2026-07-13 23:30:43 +02:00