Commit Graph

10 Commits

Author SHA1 Message Date
27669e0359 Put the whole application on the OM model, and delete what it replaced
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.

Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.

Was sich dadurch von selbst erledigt hat:

  - Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
    Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
    beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
    für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
    war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
  - Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
    rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
    vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
  - Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
    ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
  - Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
    plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
    keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
  - Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
    Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
    beantwortet werden.

Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.

Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.

Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.

Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
2026-07-27 20:02:26 +02:00
4929252f45 Seed the OM model from scratch, and stop writing dates through UTC
Alle Daten gelöscht und neu aufgebaut: 60 Organisationseinheiten, 133 Jobs,
823 Planstellen, 852 Personen, 852 Besetzungen. Die Anmeldekonten bleiben
stehen — ein Seed, der sich selbst aus der Anwendung aussperrt, ist keiner.

Der Baum kommt aus buildOrg(); der Seed entscheidet nur noch, wer welche
Planstelle besetzt. Damit fällt die halbe Datei weg: keine division_id,
team_id, manager_id, org_level, is_lead mehr auf der Person.

Zwei Dinge, die das Altmodell nicht abbilden konnte, stehen jetzt bewusst in
den Daten:

  - Vakanz ist eine Planstelle ohne laufende Besetzung, keine eigene Tabelle.
    14 Planstellen sind heute unbesetzt, drei davon mit einem Eintritt in der
    Zukunft — die Besetzung beginnt später, die Planstelle existiert schon.
  - Ausgetretene sind Vorgänger:innen auf heute besetzten Planstellen, nicht
    Karteileichen an einem Team. Vorher liessen sie deren Planstellen als
    vakant erscheinen.

Drei Teamleitungen sind unbesetzt und zwei langzeitabwesend, damit die
Hochroll-Regel überhaupt Daten hat: 76 der 809 Berichtslinien weichen von der
formalen ab. Genau eine Person hat keine Vorgesetzte, die Geschäftsführung.

Beim ersten scharfen Lauf hat der SVNR-Trigger mitten im Einfügen abgebrochen,
mit bereits geleerter Datenbank. Ursache war nicht die Prüfziffer, sondern
isoDate(): es ging über toISOString(), während makeSvNummer die lokalen
Datumsteile liest. In Österreich verschiebt das jedes Datum um einen Tag — das
gespeicherte Geburtsdatum passte nicht mehr zu dem in der SV-Nummer codierten.
isoDate rechnet jetzt lokal, wie der Rest des Seeds auch.

Damit so etwas nicht wieder erst die Datenbank leerräumt: pruefeInvarianten()
läuft *vor* dem Löschen und prüft, was sonst erst die Unique-Indizes und
Trigger abfangen — doppelte Besetzungen, überlappende Historie, Ereignisse
nach dem Austritt, und jede SV-Nummer gegen ihr Geburtsdatum. Mit --dry-run
schreibt der Seed gar nichts und meldet nur, was entstehen würde.
2026-07-27 15:06:10 +02:00
c2366e3408 Make the cut-over script safe to paste, and record the Azure design
The mapping table was declared ON COMMIT DROP. In the Supabase SQL editor
the transaction boundaries are not ours to assume, and a mapping table that
vanished between the two inserts would leave positions without assignments
and be miserable to diagnose. It is now dropped explicitly once both inserts
have run.

docs/azure-migration.md is the design for the Azure move, for review before
any code changes.

Its main finding corrects what I said when I laid out the options: I claimed
that dropping Supabase would push the security boundary into application
code. It does not. auth.uid() appears 70 times, but only one of them matters
— inside is_hr_user(), which all 58 policies call. Swapping the source of
the user id there leaves every policy valid, so the database stays the
boundary.

The risk moves elsewhere, and the design says so plainly: the user id
arrives via set_config(..., true), which is transaction-local. Outside a
transaction it sticks to the pooled connection, and the next request on that
connection runs as the previous user. So the plan makes that structurally
impossible — a single access function that owns the transaction, a lint rule
against importing the pool anywhere else, a database role without BYPASSRLS
so a missing context returns nothing rather than everything, and a test that
sends two requests over one pooled connection to prove the second cannot see
the first.
2026-07-27 14:39:57 +02:00
cce5c6b0ce Cut-over SQL: migrate the existing org data into the OM model, drop the old
One script for the Supabase SQL editor. It transforms rather than wipes:
divisions/departments/teams become org_units, every employee gets a position
and an assignment, so the org chart is populated the moment it finishes.

The Abteilungsleitung positions are created *vacant*. Nobody holds them, and
inventing holders would be worse than a visible gap — the upward rule skips
an unfilled chief, so the reporting line stays unbroken either way.

Mutations are rewritten onto the model. The reporting line is derived now,
which removes manager bookkeeping from all of them: terminate_employee no
longer reassigns direct reports at all, because they roll up on their own.
Transfer becomes what it is in OM — end one assignment, begin another.

apply_reorg, undo_reorg, create_position, delete_position and
staff_position_internally are dropped rather than rewritten: they need the
UI to move to org units first, so rewriting them now would be guesswork.
Those screens are out until the port.

Written by inspection, not by running it — Docker is not up and the project
is not linked, so this is unverified SQL. Re-reading the first draft caught
five defects that would each have aborted it: a window function inside a
JOIN condition, a jobs insert placed after the positions referencing it,
row_number() computed twice for a mapping that has to agree, a DROP VIEW
naming a view that does not exist while the real one (employees_directory)
depends on the columns being dropped, and exit_date = entry_date violating
the assignment range check. There may be more.
2026-07-27 12:58:16 +02:00
4b9c23472c SAP OM: org units, jobs, positions, and a derived reporting line
The org structure was three fixed tables — divisions -> departments ->
teams — with people hanging directly off them and a hand-maintained
manager_id. The depth was therefore wired into the schema: an Abteilungsleitung
could not exist without a migration, and a team directly under a Bereich not
at all. That is what this replaces.

The SAP OM object types, one table each:

  O  org_units             recursive over parent_id
  C  jobs                  catalogue, so many positions can share a job
  S  om_positions          belongs to exactly one org unit
  P  employees             existing table
  A012 om_positions.is_chief          "ist Leiter von"
  A008 position_assignments           "Inhaber ist", time-dependent

Two consequences worth stating, because they are the point of the exercise:

- GF/Bereich/Abteilung/Team are now a label (unit_type), not a structure.
  Adding a fifth level, or hanging a team straight off a Bereich, becomes a
  data question rather than a migration.
- Nobody hangs off an org unit any more: person -> position -> unit. A
  vacancy stops being its own concept — it is a position with no current
  assignment.

The reporting line is derived rather than stored: an ordinary position
reports to the chief of its own unit, a chief to the chief of the parent
unit, and if that chief is vacant or on a long-term absence it keeps
climbing. An unfilled Abteilungsleitung therefore needs no special case —
it is simply skipped. Both ids come back, formal and acting, so the UI can
show a stand-in as a stand-in instead of passing it off as the real manager.

The rule exists twice, as om_reporting_lines() in SQL and
resolveReportingLines() in TypeScript, because the as-of chart computes it
per date in the app and a round trip per date change would buy nothing. Two
copies drift silently — the org chart would just show a different manager
than the export — so an integration test runs both over the whole roster and
requires identical answers, plus that every line terminates at the top.

Unit tests cover the rule itself: unfilled levels, several absent levels in
a row, nobody above, a chief who also leads the parent unit, and a cycle in
parent_id, which is an ordinary column an import could get wrong.

Additive so far. The old tables still stand and the app still reads them;
the cut-over follows.
2026-07-27 12:47:14 +02:00
8282d7f581 Rename Karenz to Langzeitabwesenheit and record its type
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.
2026-07-25 14:34:28 +02:00
4be5f2264e Drop agent tooling files and neutralise spec references
- 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.
2026-07-25 11:20:09 +02:00
8d978981b0 SVNR validation, CI, and a dependency/security pass
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).
2026-07-25 11:13:10 +02:00
79f0e19bf8 Org assignment history, mobile support, and a correctness pass
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.
2026-07-24 23:38:10 +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