Commit Graph

59 Commits

Author SHA1 Message Date
a0811d75f4 Das Logo war nie kaputt — der Proxy hat es umgeleitet
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
`curl -sI` auf /brand/manner-logo.png antwortete mit **307**. Die Datei lag
die ganze Zeit da, ausgeliefert wurde sie nie: der Abgleich in proxy.ts
nimmt alles ausser _next/static, _next/image und favicon.ico, und public/
stand nicht darin. Eine Anfrage nach dem Logo lief also in den Gate, fand
keine Sitzung und bekam eine Umleitung nach /login. Auf ein <img> antwortet
der Server damit mit HTML, und der Browser zeigt den Ersatztext.

Das erklaert auch, warum es so sprunghaft aussah. Auffallen konnte es nur an
einer einzigen Stelle: die Anmeldeseite ist die einzige Seite, die jemand
ohne Sitzung zu sehen bekommt — und ausgerechnet dort steht das Logo.
Ueberall sonst ist man angemeldet, der Gate laesst das Bild durch. Einmal so
geladen liegt es im Zwischenspeicher und erscheint auf /login weiter, bis
jemand hart neu laedt.

Und es erklaert, warum keine der Aenderungen am Bild geholfen hat: weder das
Ausmisten des SVG noch der Wechsel auf ein PNG konnte etwas ausrichten, weil
die Datei den Browser gar nicht erreichte. Dass es „seit 8016d31" auftrat,
war eine Verwechslung von Ursache und Gelegenheit — dort bekam die
Anmeldeseite ihre rosa Flaeche und damit ueberhaupt erst ein sichtbares Logo
an einer Stelle ohne Sitzung.

Aufgenommen wird jetzt, was eine nicht angemeldete Person auf der
Anmeldeseite braucht: brand/ sowie icon.svg, apple-icon.png und
manifest.webmanifest, die der Browser von sich aus holt. Einzeln aufgezaehlt
und nicht als Regel ueber Dateiendungen — „alles mit einem Punkt darin"
haette auch Routen durchgelassen, die keine Datei sind. In public/ liegt
nichts Personenbezogenes und darf auch nie etwas liegen; alles, was Daten
fuehrt, geht durch withUser() und die Policies.

Fuenf Tests auf den Abgleich selbst. Die vorhandenen rufen proxy()
unmittelbar auf und gehen damit am matcher vorbei — ein Fehler dort war von
ihnen nicht zu sehen. Geprueft wird beides: dass die Dateien vorbeikommen,
und dass sonst nichts aufgeht, auch nicht ueber einen aehnlich aussehenden
Pfad wie /brandneu.

Lint, Typen, Schemaabgleich, 567 Tests und der Build sind sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:39:58 +02:00
d2d5e1dabb Drei Befunde aus dem Test: Logo, Geplant-Filter, Anstehend-Farben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m2s
**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der
Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und
war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier
aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL.

Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine
Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein
Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert
aus §1.1.

Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur
Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert
er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa
Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei —
das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum.

**Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt
vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als
geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den
No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor
dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der
Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte.
employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang
an das Richtige; falsch war die Ableitung in der Anwendung.

Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das
Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein
Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer
am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung
in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab.

Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird
nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht
betroffen.

**Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die
Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen
geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht
"Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen,
das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf.

Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und
nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb,
Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart
gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht
mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster
Kontrast 5.49:1.

Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war.

Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser
nicht gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:42:42 +02:00
f7a5c48615 Die Oberflaeche auf das Manner-CD umstellen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m45s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Farben, Schrift, Logo und Symbole folgen jetzt dem CD Manual 2022. An der
Logik aendert sich nichts: keine Migration, keine Abfrage, keine Berechtigung.

Die zwei Markenfarben stehen in §1.1 als #F69686 (Rosa) und #164194 (Blau).
Welche davon was traegt, ist nicht gewaehlt, sondern nachgerechnet: Weiss auf
Rosa kommt auf 2.18:1 und faellt damit auch fuer Grossschrift durch, Blau auf
Rosa auf 4.34:1 und reicht fuer das Logo, nicht fuer Text. Schwarz auf Rosa
sind 7.47:1, Weiss auf Blau 9.47:1. Rosa ist deshalb Flaeche, Blau ist
Interaktion — zwei Skalen, brand-* und accent-*, statt einer geteilten.

Von den 26 Stellen mit brand-50/100/200 sind nur sieben auf accent gewandert.
Der Rest meint einen Zustand und keinen Markenton: die Zeile unter dem Zeiger,
der gewaehlte Listeneintrag, der aktive Menuepunkt. Gegen die warme
Seitenflaeche ist ein kuehler Ton dort deutlicher, und Blau heisst in dieser
Oberflaeche ab jetzt "reagiert auf dich". Nach accent gingen die Faelle ohne
eigene Bedeutung — "Geplant", "Offen", der neutrale Protokoll-Chip — und die
offene Planstelle im Organigramm, die vorher ein blasses Blau war und damit
wie eine schwaechere Person aussah.

Die Funktionsfarben bleiben, was sie sind. Das Manual regelt die Identitaet,
nicht die Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt.
`info` bleibt bewusst tuerkis — Blau saehe ab jetzt bedienbar aus, und gemessen
kaeme ein blaues info dem violetten Chip auf dE 6.8 nahe, also nicht
unterscheidbar. Violett ist dabei nachgezogen: gegen den Fehler-Chip stand es
bei dE 8.0, "Austritt" und "Befoerderung" waren im Vorbeigehen dieselbe blasse
Flaeche. Jetzt dE 15.0.

Die Schrift ist Barlow. Nachgezaehlt ist das Manual zu 95 % in DIN gesetzt
(Regular 79 %, Bold 16 %); Helvetica Neue steht nur in den Visitenkarten und im
Claim. DIN laesst sich nicht ausliefern — eine Drucklizenz deckt keinen Webfont
—, und die freien Nachbauten der DIN 1451 sind Schilderschriften, bei 14 px in
einer langen Tabelle schlechter lesbar als das, was sie ersetzen. Die Variable
heisst --font-din und nicht --font-barlow: liegt eines Tages eine Web-Lizenz
vor, ist der Wechsel diese eine Deklaration.

Nebenbei zwei Dinge repariert, die vorher schon falsch waren. Die Umrandung von
Eingabefeldern, Knoepfen und Suchfeldern lag bei 1.30:1 und damit unter den 3:1,
die WCAG 1.4.11 fuer Bedienelemente verlangt; border-strong bringt 3.56:1. Und
das mitgelieferte favicon.ico liess sich gar nicht bauen: eingebettet waren
24-Bit-RGB-PNG, waehrend der Kopf 32 bpp behauptete. Alle Rastersymbole liegen
jetzt als RGBA vor, und ihr Blau ist auf den CD-Wert gezogen — samt der
kantengeglaetteten Raender, indem je Pixel der Blauanteil bestimmt und neu
gemischt wurde.

Das Logo ist das Markenlogo (§4.1), nicht das Unternehmenslogo, das §3.2 fuer
eine Anwendung mit der AG als Absender vorsaehe — es liegt nicht vor. Der
Freiraum X/3 steckt im Bauteil selbst und nicht in den Aufrufstellen, sonst
haengt seine Einhaltung daran, dass jede einzelne daran denkt.

docs/farbschema.html zeigt Token, Kontraste und Bauteile nebeneinander und
laesst sich ohne Server oeffnen.

Nicht im Browser gesehen: Anmeldung laeuft ueber das Firmenkonto und die
Datenbank ist von hier nicht erreichbar. Lint, Typen, Schemaabgleich, 524 Tests
und der Build sind sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:26:21 +02:00
99e4357c02 Let colleagues finish each other's drafts, one at a time
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m18s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m45s
Seeing a colleague's draft turned out to be half a feature: the point of
sharing it is to finish it while they are away. So writing is allowed
now -- but never by two people at once.

A draft is a single JSONB field. Whoever saves writes the whole state,
not the changed field, so two open wizards overwrite each other
completely and the second person sees nothing wrong: their own state is
right there on screen. That is why writing stayed with the owner until
now, and a lock is what makes giving that up safe.

The lock lives in the row (locked_by, locked_at) and is enforced by the
update and delete policies, not by the application. It expires, and that
is the important half: releasing happens when the wizard closes, and a
closed laptop never closes a wizard. Without expiry one crashed tab
would take a draft away for good -- worse than the problem being solved.
The wizard refreshes its lock while open so a long form does not lose it
mid-way.

Delete had to widen too, which reads like more than was asked for: the
wizard deletes the draft once the person is hired. Without it the hire
would go through and the draft would sit there forever. The card still
only offers delete on your own drafts.

Four of five mutations against the lock go red. The fifth -- dropping
`!open` from the refresh guard -- does not, because freigeben() already
nulls the ref the interval checks. The condition stays as the readable
statement of intent, now with a comment saying so.

Not run against a live database here; the CI migration job is the first
real execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:43:43 +02:00
f17d299045 Lehrling, and a field that stops being named after its values
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 10m51s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
worker_type had two values because the field was named after them:
"Angestellte:r / Arbeiter:in". Lehrlinge are the third social-insurance
category in Austria; until now they were filed as one of the other two,
which they are not -- and which skewed every report grouped by this
column by exactly those people.

Adding the value is one line. The label was the work: the field was
called after its two values in six places, and each of them becomes
wrong with a third. They now read "Beschaeftigtengruppe", the name the
import has used all along.

One label deliberately keeps the old wording: app_feld_karte() in the
database. That string is not a caption there but a key -- stored rows in
employee_history and pending_changes carry it, and the map is how
reverting or correcting a history entry finds the field again. Renaming
it without rewriting those rows would make every older entry for this
field unrevertable, and nobody would notice until they tried.

The migration's assertion reads pg_enum rather than comparing against
'Lehrling'::worker_type: migrations run inside a transaction, and
Postgres refuses to use a freshly added enum value in the transaction
that added it. This has not been run against a live database here -- the
CI migration job is the first real execution.

Three hand-kept lists of the same enum (reports, import, the form) now
have a test holding them to one another, each mutation-checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:28:49 +02:00
2838919e42 @
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m12s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Name every draft, and put Vertrag before Angehoerige

Two things the dashboard and the hire wizard were getting wrong.

The drafts card named the author only on other people's drafts. With
foreign and own rows side by side that reads as an inconsistency, not as
information: the eye has to work out that a missing name means "mine".
Now every row says it, "von mir" on the own ones -- the same wording the
notes in the bell already use.

In the wizard, Angehoerige stood before Vertrag. What a contract is made
of -- entry date, working days, a fixed term -- is on paper before the
conversation happens; relatives the person brings along, often on the
first day. The optional step came before the one the hire rests on.

Swapping them meant touching the part that would have broken silently:
the per-step validation was a positional list that had to line up with
STEP_LABELS by hand. Reordered labels alone would have left the checks
where they were -- "Weiter" on Vertrag would have validated the
relatives and waved an empty entry date through, until the database
refused it at the end. The checks are keyed by step name now, so they
travel with the step.

Drafts saved before this land on the step number they stored, which now
points at a different page. Nothing is lost -- the payload carries every
field -- but somebody resuming an older draft may open on Vertrag where
they left Angehoerige.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@
2026-09-10 16:35:20 +02:00
eeaf210e78 Let the same choice open notes and drafts
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
The picker in the bell now governs both lists, so note_subscriptions is
renamed to colleague_subscriptions -- a name that only mentions notes would
mislead the next reader.

Reading and writing a draft now reach differently far. hire_drafts_owner
(for all) is split into four policies: select lets in your own drafts and
those of the people you added, while insert/update/delete stay with the
owner. A draft is unfinished work with no lock and no history; two people
writing into the same row would overwrite each other silently.

That split forces a change in the actions: a policy does not reject a write,
it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row
count instead of reporting success over a row that never changed.

The card shows a foreign draft with its author and without Fortsetzen or
Loeschen -- offering a button that reliably ends in a database error is a
promise without cover.

check-schema-types.mjs learns `alter table ... rename to`; without it the
drift check reports one rename as two errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:05:40 +02:00
e8e675fd07 Turn the note filter around: yours by default, colleagues added
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.

The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.

What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.

Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.

Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:36:38 +02:00
eb25369d1d Let the truncated Anstehend list open
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m35s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m14s
"… und 5 weitere Ereignisse in diesem Zeitraum" was a sentence to read.
It is now a button: click it and the rest unfolds in place, click again
and the card goes back to eight rows.

Until now the only way to see the remaining entries was to narrow the
period or the kinds — which changes the question rather than the answer.

This happens in the browser, not through the URL, unlike the period and
kind filters. Those go through the address because a longer period brings
rows into play that were never loaded; here every entry in the period is
already on the page and the eight was purely presentational. A round trip
would mean waiting for data that is already there, plus a history entry
for something nobody wants to go back to.

The list moved into its own component so the state has somewhere to live.
KIND_LABEL and the item type moved with it, since they only describe this
list.

The button only appears when there is something to unfold, and once open
it offers the way back — otherwise the card stays long for the rest of the
session because somebody looked once.

Verified: 10 tests, five mutation-checked (ignoring the state, dropping
the way back, showing the button with nothing to unfold, losing the
singular, dropping aria-expanded each turn them red). 487 tests,
typecheck and build clean. Not seen in a browser — login goes through the
company account and the database is unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:20:19 +02:00
99b1df9735 Choose whose notes reach your bell
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m49s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m24s
The bell is a shared pile: every active HR person sees every open note,
regardless of who wrote it. That was agreed and it stays the default —
this narrows it, it never widens it. You can now untick colleagues whose
notes you do not want to see.

What gets stored is the *exceptions*, not the selection. The difference
shows the day someone new joins HR: had the selection been stored, she
would be invisible to everyone until each person ticked her, and nobody
would notice her follow-ups piling up. This way she is visible from day
one and hiding her is a deliberate act. Same reasoning that made notes a
shared inbox in the first place — the silent gap is worse than a row too
many.

Own notes always come through: `note_mutes` rejects a self-reference, and
the predicate says so again rather than depending on a check constraint
staying put. Notes with no author come through too — hiding one because
nobody knows who wrote it is exactly the loss this list exists to prevent.

The rule lives in lib/notes.ts as one SQL expression because two places
need it: the bell in the header and the "Anstehend" card on the dashboard.
Two copies drift, and then the card counts something the bell does not
show.

No SQL function and no audit row, unlike anything that touches employee
data — this is a personal display preference, and an audit trail recording
every tick would make finding real changes harder. Same pattern as saved
reports and hire drafts, and the owner policy on note_mutes means a row
for someone else cannot be written even with invented values.

The checkbox flips immediately and flips back if saving fails; the list
gets clicked through several at a time and a round trip per tick feels
like hesitation.

Verified: 19 tests, five mutation-checked (or→and, dropping the own-notes
clause, inverting `not exists`, inverting the default, and losing the
email fallback each turn them red). Typecheck, lint, schema drift, 477
tests and the build are clean. Not seen in a browser: login goes through
the company account and the database is unreachable — the migration is
reviewed but has not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:03:24 +02:00
c6cff9656e Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m3s
2026-09-08 16:51:14 +02:00
405d708bc4 Sort from the column headers, all seven of them
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m2s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m9s
The dropdown is gone; each column header is now a link that sorts by that
column, with an arrow showing the direction. Clicking the column already
sorted reverses it; clicking a different one starts ascending again — going
from "Eintritt, newest first" to "Name" should give you names from A, not
inherit the previous direction.

Names sort by surname and then forename, as asked. Both parts reverse
together: turning only the surname would give Z-A across surnames but A-Z
within each one, which is visible immediately among the fifteen Aigner.

Three of the seven columns are not on the employee row. Bereich and Team
hang off the position, Standort off a lookup table, so they are fetched as
correlated subqueries rather than joins. That is not a style preference: the
same filter chain produces the page *and* the count, and a join onto
position_assignments would double every person who has held more than one
position over time — the line above the list would read 1,203 for 867 people.

Bereich is the level below the company, so it needs to walk up from the unit.
No recursion: org_unit_type has exactly four levels, so two hops up cover it.
Everything sorts `nulls last`, otherwise reversing the direction floats every
person without a position or location to the top.

The expressions live in lib/employee-sort.ts rather than in the page so the
generated SQL can be read in a test — the failure mode here is silent, the
list still shows fifteen rows, just the wrong ones. Eighteen tests, and the
rules are mutation-checked: dropping the forename, dropping the id tiebreaker,
dropping `nulls last`, sorting the location by its uuid, and shortening the
Bereich walk each turn them red.

Not seen in a browser: login goes through the company account and the database
is unreachable. Typecheck, lint, 458 tests and the build are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:33:09 +02:00
b87c8ad64c Remove Supabase
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00
5c310c3a58 Let the employee list be sorted A-Z or Z-A
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m12s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m29s
Sorting lives in the URL, not the browser. The list is built on the
server and fetched a page at a time, so a client-side sort would only
reorder the fifteen rows on screen — with 867 people that promises an
alphabetical list and delivers something else on page 2.

The sort key is the surname, because that is how the column reads:
"Aigner, Manuel", and whoever looks for someone looks under A. Postgres
runs with the Austrian collation, so Ö sorts with O rather than at the
end of the alphabet.

Only the surname reverses. The id stays ascending: it decides nothing
except ties, and it exists to keep the order total across page
boundaries. Reversing it too would still be deterministic but would flip
the fourteen Winklers relative to each other for no reason anyone asked
for.

The select sits in the filter bar rather than in a clickable column
header — a header would suggest it sorts what is on screen.

Verified: compiled SQL is `order by last_name desc, id` for Z-A; the
parse and direction rules are covered by tests that were mutation-checked
(breaking each rule turns them red). Not verified in the browser — the
login goes through the company account, and the Supabase instance no
longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:02:40 +02:00
19e3170b00 Print a checklist without printing the application around it
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m6s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m22s
The checklist gets a print view: one A4 page, header carrying personnel
number, name, date of extract, entry date and position, the items in two
flowing columns so twenty-five fit, and two signature lines at the foot.
It prints the *state*, dated — not a blank form.

The default filename in the save dialog comes from the document title,
which is set to the agreed convention:

  20260818_2884_Aigner-Manuel_Onboarding-Checklist

Surname first, like everywhere else in the application, so a folder of
these sorts by person and within a person by date. Umlauts are resolved
rather than stripped: the naive route (NFKD, then every non-ASCII to a
dash) turns "Müller" into "Mu-ller", because decomposition splits the
umlaut and the diaeresis becomes the dash. "Weiß" needs its own rule —
it has no decomposition and would otherwise vanish.

The reported defect: the printout carried the application's own top bar
— hamburger, bell, "Neueinstellung", sign-out. Those are controls; on
paper they are decoration, and on a checklist filed in a personnel
record, misleading. The rule now sits on AppShell rather than on this
one page, so the org-chart print view — which had the same problem —
gets it too, along with anything printed later. The shell's padding goes
with it: the type area is set by @page on the print page itself, and the
shell's would have been added on top.

The browser's own header line (date, title, URL) is separate — that is a
checkbox in the print dialog, not something CSS can reach.

440 tests pass, including 13 new ones pinning the filename convention.
Not yet seen in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:19:58 +02:00
521e4ecf00 Show the Offboarding tab from the day the exit is recorded, not the day it takes effect
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m6s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m26s
The tab was keyed off employee.status === "Ausgetreten", and that stays
wrong for weeks: terminate_employee writes exit_date immediately no
matter how far out the date is, but only flips status once the date
itself arrives. A termination entered today for four weeks out left the
tab invisible for the entire notice period — exactly the stretch in
which IT access, hardware and deregistration actually get worked
through, and exactly where the checklist was supposed to live "next to
Onboarding," per the report that caught this.

The rule now reads exit_date instead: not null, and not a No Show
(which sets exit_date too, to the entry day, but never worked a day and
gets no checklist). rehire_employee resets both exit_date and
exit_reason to null, so a rehired person's tab still disappears the
same way it did before — nothing about that case changed, only the
signal the check reads.

Verified against the real database with the exact shape from the
report: a termination dated 30 days out. Status stays "Aktiv", the
checklist exists immediately, and the tab's own predicate says yes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:41:07 +02:00
f85731dde5 Give exits their own checklist, next to the entry one
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m42s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m35s
The offboarding list was a checkbox fieldset inside the exit panel — four
items, never sent anywhere. Nothing in terminateEmployee's payload
carried them; ticking a box there recorded exactly nothing.

It's replaced with the same kind of list the entries got: its own tab,
appearing the moment an exit is recorded, with one item per row, a
comment on each, and — unlike the fieldset — a record of who touched it
and when.

The eleven items come from the same printed sheet as the entry list.
Nine are plain checkboxes. Two are text fields under "Vermerke":
remaining vacation and the balance transferred for payout — the sheet
names "Überleitung Salden für Auszahlung" twice, once as a task to do
and once as the actual figure, and those are genuinely two different
questions, kept as two items. Where the sheet still says "GKK" rather
than today's "ÖGK", it's left as written — that's the name the process
runs under internally, not a typo.

No Show gets no list. Never having worked a single day, there's no IT
access to revoke, no GKK registration to undo, no Dienstzettel to
collect — an empty checklist there would be a label with nothing behind
it. Both the tab and the auto-creation on exit check for this
specifically, not just the "Ausgetreten" status that No Show shares with
a real exit. Rehiring the same person hides the tab again — the data
stays, since it happened, but a checklist for someone currently working
has nothing to point at.

The engine (what counts as done, how progress is computed) moved into
lib/checklist.ts so onboarding and offboarding can't drift into two
different ideas of "done" the way two independent copies eventually do;
lib/onboarding.ts and lib/offboarding.ts bind it to their own item list,
and the tab UI is a single ChecklistPanel bound the same way.

Checked against the real database: a real exit creates all eleven items
in the same transaction as the exit itself; a No Show creates none;
rehiring flips the tab off while the old answers stay queryable. One
false alarm during that check turned out to be the user's own clicks on
a real employee's onboarding list, made in the browser while trying the
earlier feature — left untouched, not test debris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:25:58 +02:00
82d07f0d95 Put the onboarding checklist where the file is
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m24s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m51s
The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.

Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:

  Haken   — the normal case. The Meldezettel is there or it is not.
  Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
            not decoration: "nein" is a finding, "not asked yet" is not.
            One checkbox cannot say both.
  Text    — shoe, shirt and trouser size. The value is the point;
            ticked off it would be worthless.

Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.

Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.

The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.

A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.

Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:40:02 +02:00
e30923e781 Give a position a cost centre
There was none anywhere in the model, so no personnel-cost figure could
be produced at all, and an open position could not say whose budget it
would charge — which is the first question asked about a vacancy.

It hangs on the position, not on the person: the seat costs money even
when nobody sits on it. That is exactly the vacancy case. And not on the
org unit either, although it usually follows from one — a single seat
can be charged elsewhere (project, shared function) without the unit
moving.

As its own dated assignment table rather than a column, because
reassigning is an event with a date. Last year's costs have to stay
where they were incurred; as a column, every change would silently
rewrite every past report. Half-open [valid_from, valid_to), like
position_assignments and om_positions — in SAP OM this is A011.

25 cost centres seeded from the org tree: one per company, division and
department, with teams charging to their department, because a team is a
span of control and not a budget. All 823 positions were assigned from
their own start date, none left over. The number is the first five digits
of the org number, so it can be traced rather than looked up.

Reassignment refuses three things, each checked: the same cost centre
again, a switch on the day the current one started (that period would
never have been in force, and the range constraint says so), and a date
before the position exists.

Verified against the real data, which turned up a defect worth keeping:
a position that starts in the future is charged only from its start, so
asked about today it had no cost centre — and future positions are
exactly what the vacancy list is for. It is now read at the position's
own start date.

Two audit entries from the probe could not be deleted through the
application (the log has no delete policy — correctly), so I removed
them with the admin connection.

Still open, and the reason this is only the first of the three fields I
proposed: location and planned FTE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:57:09 +02:00
3a4f44318c Derive the filter test's list from the registry it tests
Adding the follow-up kind turned one of these tests green-for-the-wrong-
reason and one red: both had the three kinds written out by hand, so
"all of them are selected" no longer meant what the name said. That is
the failure mode a hand-copied list has — it does not break loudly, it
drifts.

The list now comes from ANSTEHEND_ARTEN, and the two cases that depend
on completeness build their input from it.

I committed the previous change with this test red. That was wrong; it
should have blocked the commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:46:24 +02:00
10f8f1f2d5 Keep a note on the overview until somebody ticks it off
A note with a follow-up date is a task, and the overview is where tasks
are looked for. Until now it lived only in the employee file, which is
the one place you go when you already know who you are looking for.

Follow-ups behave differently from everything else on that card, and the
difference is the point: an entry on Monday is over on Tuesday, an
unfinished task is not. So there is no lower bound on the date — what
was due and never ticked off stays, marked overdue in red, sorted to the
top because it is sorted by date. A task that drops out of the list by
itself is a forgotten task.

Only "Erledigt" removes it. A note without a follow-up date never
appears: it is a record, not a task.

Checked against the live database — an overdue one and an upcoming one
appear, one without a date and one beyond the chosen period do not, and
ticking the overdue one off removes exactly it. The probe notes were
deleted again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:44:48 +02:00
6957b95a97 Let the overview say what "upcoming" means
Sixty days and all three kinds was a guess, and it was the only one on
offer. Payroll cares about next month; the person filling a vacancy
cares about entries and nothing else. The card now takes a period and a
set of kinds.

The choice lives in the address rather than in the browser, because it
has to: the page is built on the server, and ninety days pulls in rows
that were never loaded at sixty. Filtering client-side would silently
cap the answer at whatever the first query happened to fetch. It also
means a filtered overview can be sent to someone and opened again the
same way.

Deselecting every kind returns to all of them. An empty card is not an
answer to a question nobody asked, and the way back would otherwise be
one click further than the way in. The default period and the full set
are absent from the URL instead of written into it, so a shared link
carries only what was actually chosen.

Anything the address cannot be trusted to hold is rejected: an unknown
period falls back to sixty rather than reaching the query, which would
otherwise be an invitation to ask for ten years of rows through a link.

Eight rows still, with a count of what did not fit underneath — this is
an overview, and the employee list is where lists belong.

Not verified in a browser: the built-in preview has no company sign-in,
so the page redirects to the login before it renders. Types, lint and
386 tests pass, and the filter's behaviour is covered directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:56:05 +02:00
16216c5537 Let a planned absence be called off
The old refusal read: "Diese Abwesenheit ist noch nicht wirksam. Sie muss
über den Vorgang selbst abgebrochen werden." There was no such way. The
row sat in the file, the scheduled change kept running toward its date,
and nothing could stop either one.

That is not hypothetical. One person went absent in July, came back in
August, and still has a second return booked for the first of September
— recorded while they were already working again. The guard added
yesterday stops a third from being written; it does not remove the one
that exists.

Absences are called off whole, not field by field. For a planned
contract change the scheduled payload gets the affected fields lifted
out of it and runs on with the rest; an absence has no fields in that
map, and half an absence is not a thing anyone means. So the whole
scheduled change is cancelled, and what it had already noted on the
person goes with it: the date they were to be away from, the date they
were to come back on. Left behind, the profile would show an absence
with no event behind it. If the absence is still running, the return
date planned when it began applies again.

The link between the row and the scheduled change had to exist first —
start_karenz and record_karenz_return now record it. Existing rows get
it backfilled, but only where one running change of that kind falls on
that person and that day. Where two would match, the row keeps refusing:
guessing which process to cancel is worse than refusing to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:44:21 +02:00
861c47b757 Correct an entry date, and stop returns without an absence
Three things, all from the same screenshot.

The entry date can now be corrected. The Eintritt entry gets an edit
button — date only, no delete, because it is the start of the timeline
and a person without one has no beginning. Unlike every other entry it
needs no recorded before-values: the old date is on the employee row, so
this works on rows written long before any of this existed, which is
exactly the case that matters.

What hangs off that date is checked: no other event may precede it, exit
and absence start may not fall before it, and the first position
assignment moves with it — left behind it would leave days of employment
with no post, or a post with nobody in it. Someone already working
cannot be given a future entry date either; without that check a person
who has been here for years could be turned into a planned entry, and
the status derivation would agree.

That last rule came out of the rehearsal finding a hole: my first probe
picked a person with no other history rows, so the "nothing may precede
it" check had nothing to compare against and a date in 2099 sailed
through.

Second, the screenshot showed two returns from one absence, and the data
confirmed it: one person with two Rückkehr entries and a third still
scheduled, recorded while they were long since active. record_karenz_
return never checked that there was an absence to return from. Now it
does, and it refuses a second scheduled return — which would have
silently overwritten the first on its effective date.

Third, the history is filterable: upcoming versus done, a date range,
and the event types that actually occur in that file. The count of
upcoming items shows without filtering, because "what is coming" is the
usual reason to open the tab at all.

Still not deletable: Versetzung, Beförderung, Austritt, Wiedereintritt,
Reorganisation. Undoing those means restoring position assignments, and
that deserves its own step rather than being tacked onto this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:31:41 +02:00
d2f4a7aab7 Ask for a residence permit only where one is needed
Employees from outside the EU, the EEA and Switzerland need a residence
permit, and HR needs to know when it expires — about eighty people in
the current data, across Türkei, Serbien and Bosnien. Two columns, the
same shape as the dismissal protection: a flag and a date that only
means anything with it. The date is optional, because an open-ended
permit has none and a mandatory field would force an invented one.

The nationality coupling deliberately stays out of the database. Putting
it there would mean keeping the country list in two places — SQL and
lib/countries.ts, where the picker needs it anyway — so an EU accession
would become a migration instead of a line in a list. Worse, correcting
somebody's nationality would fail the constraint while the old permit
was still attached, which is exactly the moment someone is fixing a
mistake. The UI decides whether the fields appear, and clears them when
the nationality moves into the free-movement area.

So the list is the load-bearing part, and it is tested: 31 entries, all
of them values the picker can actually produce, no duplicates, no third
countries. A missing nationality reads as "no permit required" — an
unanswered question is a reason to record it, not to demand papers.

The permit shows on the Stammdaten tab only for the nationalities it
applies to. A line reading "Aufenthaltstitel: Nein" under an Austrian
citizenship would look like information rather than a question that does
not arise.

Filter by it and by when it expires — the question behind that being
"whose permit runs out next quarter" — plus columns in the export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:27:26 +02:00
384bdb4fb3 Let an absence be taken back, in the right order
A long-term absence recorded by mistake could only be undone by booking
a second event on top of it — leaving two entries in the file, the first
of which never happened. Karenz and Rückkehr can now be deleted and
corrected like the other entries.

For that to restore anything, the two operations first had to start
recording what they overwrote. start_karenz and record_karenz_return now
keep before/after the way change_employee_data does: status, kind of
absence, start, planned return — and for a return also employment type,
hours and the part-time variant. Without that there is nothing to revert
to, only a sentence.

The ordering rule HR asked for is enforced in the database, not just in
the UI: an absence cannot be deleted while a later return exists. A
return standing on its own would be a return from nothing, and the
person's status would derive from an entry whose starting point had been
deleted. Delete the return first and the absence frees up.

Rehearsed end to end on real data: absence recorded, return recorded on
reduced hours; deleting the absence refused; deleting the return put the
person back on Karenz with the original hours and the part-time variant
cleared; deleting the absence then put them back to Aktiv with no trace.

Rows written before today carry no before/after and stay untouchable,
with the reason they already gave. Planned absences are refused too —
they have their own operation, and their fields have no place in a
pending payload, which is why app_feld_karte carries a null group for
them rather than a plausible-looking wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:13:01 +02:00
f5ace8af2e Make the part-time arrangement a state you can report on
Last step moved the four part-time arrangements out of the absence list
and recorded the reason in the history text. That answered "what
happened" but not "who is in one right now", and the profile showed
nothing at all. So it becomes a real field: teilzeit_art, with an
optional end date.

The objection I raised then still holds — a state goes stale, because
nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is
the answer to it: with an end date a report decides for itself what is
still running instead of trusting that someone maintained the row. Left
empty it means "open end", which is an honest thing to say.

It runs through the ordinary change machinery rather than beside it. It
sits in app_feld_karte, so it shows up in the history as a field with
before and after, and can be corrected there like any other. The
description suffix from last step is gone — writing the same thing twice
is how two versions start disagreeing.

Reporting: filter by variant, by "in one at all", and by when it ends;
group headcount by variant, where the absence of one reads "Keine"
rather than a dash, because in a report that is an answer and not a gap.
Plus columns in the export and the import.

One gap found while rehearsing, and only because the probe happened to
pick a return date in the future: a scheduled return carries its payload
through pending_org_changes, and that payload did not include the
variant. Someone would have come back on reduced hours in April with the
reason gone. The daily run now carries it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:07:41 +02:00
e6554e7982 Move the part-time arrangements out of the absence list
Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit and
Wiedereingliederungsteilzeit were offered as kinds of long-term absence.
Recorded that way, the person counted as absent: they dropped out of
headcount, their reporting line fell to a stand-in, and reports stopped
counting them — while they were in the building every week, just for
fewer hours. A part-time arrangement is not an absence; it is a change
of hours.

They now sit where they belong. Wiedereingliederungs- and Elternteilzeit
appear when recording a return from absence, as the reason someone comes
back on reduced hours — both typically begin exactly when the absence
ends. Bildungs- and Pflegeteilzeit appear under "Daten ändern" beside
the hours, next to the ordinary contractual change.

The reason is recorded with the change, not as a state on the person. A
state would have to be maintained, and nobody goes back to note when a
Bildungsteilzeit ended; a field that quietly goes stale is worse than
none. In the history it stands next to the value it explains, and stays
readable for good.

The check constraint on absence_type is deliberately untouched. Three
people carry the old values right now — two Pflegeteilzeit, one
Wiedereingliederungsteilzeit. Forbidding them would make existing rows
illegal. They are gone from the list of choices; the history stays
readable. Those three are worth revisiting, but that is a data decision,
not a code one.

Rehearsed against real data: an hours change with a reason and one
without, a reduced return with a reason and an unchanged one — checked
by reading both new history rows rather than "the latest", since now()
stands still inside a transaction and made an earlier probe report a
false negative.

I also overwrote tests/unit/absence.test.ts instead of extending it. The
original cases are restored; the diff is 49 added lines and 3 changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:56:21 +02:00
6681dcda77 Flag people who cannot simply be dismissed
Works council members, expectant mothers, parents on leave, registered
disabled employees, apprentices — each has its own rules that come
before a dismissal. The tool does not judge whether one is lawful, but
it must not stay quiet about it either, and looking it up on the
contract tab is exactly the step that gets skipped under time pressure.

So: a checkbox, an optional end date, and a red warning at the top of
the termination panel naming the date — or saying plainly that no end
was recorded. It shows for a no-show too; the protection runs from the
start of the contract, not the first day worked.

The date is optional on purpose. A works council mandate has a known
end, a pregnancy does not, and a mandatory field would force an invented
number. A constraint says only what cannot be: an end date without the
flag, which would be a leftover nobody could interpret.

The field goes the whole way through — hire, data change, contract
sheet, export, report criteria (as a yes/no and as a date range), and
the import. A field that exists in one screen and not the next is how
people stop trusting the numbers.

Terminating is now offered for planned entries as well, labelled "Nicht
angetreten", with No Show preselected. Without it a person who never
turned up stayed a planned entry forever, since nothing else can end
one.

One finding worth recording: tsc has been reporting success on a broken
program. A generated file under .next got corrupted when a build ran
against a live dev server, and its syntax errors suppressed semantic
checking everywhere else — two genuine type errors in this change went
unreported until I typechecked with .next excluded. The file is removed
and the ordinary typecheck is meaningful again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:44:00 +02:00
0144267a59 Record people who never turned up
Someone hired who then does not start needs an exit reason of its own,
and until now the case could not be recorded at all. Terminating on the
entry date failed on chk_assignment_range: the assignment was closed
with valid_to = valid_from, and an empty interval is forbidden there.
Moving the exit to the next day would have claimed a day of employment
that never happened — headcount, tenure, every as-of report.

"No Show" is now an exit reason, and it behaves differently in three
ways.

The exit date is always the entry date, whatever the caller passed. That
is what makes "never active" true rather than asserted: a person counts
as employed when their exit date is *after* the reporting date, and here
it never is. The status derivation needed no change at all — it already
says Geplant before the entry date and Ausgetreten from it on.

The position assignment is deleted rather than closed. The post was
never filled, it goes back to being open, and nothing records a holder
who never held it.

The status column goes to Ausgetreten immediately, even for an entry
still in the future. Otherwise it would read Geplant forever — nothing
runs later to correct it.

A constraint holds the first of those regardless of the path in,
including the import: exit_reason is distinct from 'No Show' or
exit_date = entry_date. "is distinct from" rather than "<>" so an empty
reason does not evaluate to null and slip through — the same three-
valued trap that let an earlier check pass the case it was written to
stop.

The dialog locks the date field when No Show is picked and says why, so
nobody types a date that would then be silently overridden. The
offboarding checklist is hidden: nothing was ever handed out.

Rehearsed against real data — a planned entry with a 2099 date passed
in, which came back as the entry date; derived status across three
reporting dates never Aktiv; a direct write with a mismatched date
refused; and an ordinary termination unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:33:14 +02:00
8a76b3688f Show people surname first
Employee names now read "Winkler, Hannah" wherever a person appears in a
list, a table, a heading or a tree node. That is the order a personnel
list is kept in, it is the order people are looked up in, and it finally
matches the sorting — the employee list has always been ordered by
surname, which made an alphabetical page look unsorted.

The name was being assembled inline in about twenty places. A rename
that catches half of them is worse than none, so it now goes through
fmtName in lib/format.ts and every display site calls it.

Sentences keep the natural order: "Hannah Winkler wurde versetzt" reads
like German, "Winkler, Hannah wurde versetzt" reads like a form. So the
toasts are unchanged and only labels moved.

Two things the change would have quietly broken:

The org chart's own filter matched against "first last". It now matches
either order, with or without the comma, so typing what you see works
and so does typing what you remember.

The print model sorted by the last word of the composed name, which
happened to be the surname and is now the first name — every printed
unit would have come out sorted by first name. It sorts on the surname
field itself now, which is what it meant all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:44:03 +02:00
a3fac47f47 Give each characteristic its own line, and name the car
The contract sheet had one field, "Merkmale", holding whatever applied,
comma-separated — and a dash when nothing did. Two problems in one row.
A dash cannot distinguish "has no company car" from "nobody ever
answered the question", and the entry read "Dienstwagen" without saying
which kind, which is the thing worth knowing since electric vehicles are
tracked separately.

Betriebsrat, Dienstwagen, laterale Führung and C-Level are now four
lines like every other line on the sheet, each with Ja or Nein. The
company car shows its drivetrain instead: E-KFZ or Verbrenner.

That label existed in three places — the dropdown, the hire summary and
now here. It lives in lib/dienstwagen.ts, so the same car cannot end up
named differently depending on which screen you are looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 07:06:58 +02:00
08d2740690 Let planned changes be taken back and corrected too
Deleting and correcting a history entry stopped at the present: anything
not yet effective stayed put. That was not a principle, it was a missing
link. A planned change lives as a payload in pending_org_changes, and
nothing tied it to the history row — only a person and a date, and the
data already holds an Eintritt and a Vertragsänderung sharing one. So
employee_history now carries pending_id, set by change_employee_data
when it schedules something.

One planned change can carry two history rows: Stammdaten and Vertrag
are kept apart but scheduled together. Taking one back therefore strips
only that group's fields from the payload, and cancels the operation
only when nothing is left. Correcting one rewrites its group and the
effective date, and touches no employee data — the change has not
happened yet.

An entry stays on its side of the present. Pulling a planned change into
today, or pushing an effective one into the future, would mean adjusting
the employee record and the pending payload in opposite directions;
that is what the real operations are for.

Existing rows were linked where exactly one running operation matched
the person and date and no other row had claimed it. All five of them
matched. Anything ambiguous would have kept the old refusal, which now
says the actual reason.

The edit dialog surfaced a bug in useDialogFocus that predates it: the
effect depended on the identity of onClose, which almost every caller
rebuilds on render, so it re-ran after each keystroke and its cleanup
pulled focus back to whatever opened the dialog. Any dialog with a text
field would have accepted one character. It never showed because until
now no dialog kept its own state next to its own onClose.

Rehearsed against real data: a two-row planned change corrected, one row
taken back with the operation continuing on the rest, the second taken
back with the operation cancelled, and both refusals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:35:51 +02:00
33d053ce75 Match search words at the start of a word, not anywhere inside one
Searching "Winkler H" returned all seven Winklers instead of the one
Hannah. Each word was matched as a substring, so "H" hit T-h-omas,
Kat-h-arina and CNC-Dre-h-er:in — every row. The shorter the input, the
more useless the result, and an initial is the shortest input anyone
would type.

A word now has to match at the start of a word: either the haystack
begins with it, or a space does. The haystack is first name, last name
and job title joined, with hyphens, slashes, colons and dots flattened
to spaces, so "dreher" still finds CNC-Dreher:in and "cnc" still finds
both the Dreher and the Fräser.

Checked against the live data before and after: "winkler h" now returns
Hannah Winkler alone, "h winkler" the same in either order, "winkler
kat" the two Katharinas, "dreher" the twelve CNC-Dreher.

The trigram index on the concatenated name no longer applies, which is
the price. At under nine hundred rows the scan is a few milliseconds; an
index on the same expression brings it back when that stops being true.

LIKE's own wildcards are escaped now — typing "100%" searched for
everything before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:22 +02:00
6297288c13 Let an entry be taken back, along with what it did
HR can now delete a history entry, but only where deleting one is an
honest thing to do — and deleting it also undoes it.

The rule they asked for is the interesting part: the last valid change
wins. Deleting an entry walks its fields one at a time. If a later entry
touched the same field, the current value stays — that later change is
the one in force. Otherwise the field goes back to what the deleted
entry recorded as its "before". So the middle of three entries can be
removed without an old value overwriting a newer one.

Four kinds of entry refuse to be deleted, each saying why in the place
the button would have been. Eintritt anchors the timeline. Transfers,
promotions, absences and exits moved positions and status — they have
proper operations for that, and guessing backwards is how you corrupt an
org chart. Anything not yet effective hangs off a planned change, and
that link is not trustworthy: there is no key between a history row and
its pending row, only a person and a date, and the data already has an
Eintritt and a Vertragsänderung sharing one. Matching on the date would
eventually cancel a change nobody meant. And entries from before the
history carried values have nothing to fall back to.

Confirmation is not "are you sure" — that question gets a reflex yes by
the third time. The dialog says what will be different afterwards: which
field goes back to which value, and which one stays because something
later claimed it.

employee_history keeps its append-only policies; delete_history_entry is
SECURITY DEFINER and checks the permission itself in its first line. The
audit log keeps the deletion with the values that were removed, and the
audit log genuinely cannot be edited.

The rule lives twice — in SQL and in lib/history.ts. The database is the
authority; the copy exists so the UI can hide a button that would fail
and print the reason instead. Rehearsed against real data in a
rolled-back transaction first: the later change held, the untouched
field reverted, all four refusals fired.

Also corrected in the data catalogue: I had written that
require_hr_admin was called by nothing. It guards all sixteen mutating
functions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:56:48 +02:00
5f50cb97f3 Let the history say what an address was before
HR reported it from testing: change someone's address and their history
shows "Geänderte Felder: Adresse, Ort" — the new address is on the
Stammdaten tab, the old one is nowhere. It was recorded, but only in the
audit log, which is a different page sorted by time and actor rather
than by person. So you had to already know what you were looking for to
find out whether an address had ever changed, let alone what it used to
be.

The field-by-field diff was being built anyway and written to the audit
log. employee_history now carries the same list, and the person's
history renders it as an expandable Feld / Vorher / Nachher table — the
same table the audit log uses, lifted into a shared component so the two
views don't drift into reading differently. It expands with <details>,
so the values are in the page: findable with Ctrl+F, present when
printed, no script involved.

The duplication with audit_log is deliberate. A person's history should
be readable on its own, including after the log is eventually thinned by
a retention rule.

Rows written before today stay without values. They could only be
reconstructed from the audit log, and the link is not reliable — no key,
only a timestamp and a person. Honestly empty beats plausibly wrong.

The migration was generated from the live function definition rather
than retyped, and the diff is four lines: two column lists, two value
lists. It carries a self-check that raises if either insert failed to
pick up the new column, and it was rehearsed inside a rolled-back
transaction against real data first — the probe confirmed the old street
name lands in the history row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:57:10 +02:00
00df973824 Stop the print preview measuring itself into a freeze
Opening the org chart PDF preview locked up the browser tab. The
measurement that fits each sheet to the page fed itself: the effect
listed onFaktor in its dependencies, and onFaktor was an arrow function
created fresh on every render, so the effect re-ran after every render.
It measured, reported the scale, and the report called setState with a
newly built object every time — new object, so React saw a change,
re-rendered, and the effect ran again. Measure, render, measure, until
React gave up with "Maximum update depth exceeded".

Two changes, and the mutation test says either one closes the loop on
its own: the callback now lives in a ref so the effect depends only on
the sheet identity, and the reducer returns the previous state unchanged
when the scale has not moved. Both are worth keeping — the ref stops the
effect from re-running, the guard stops pointless renders.

This shipped broken, and the reason it shipped is in the test file now.
Every element in jsdom is zero pixels, so the measurement bailed out on
its first line and the feedback never started; nine tests covering the
selection, the page count and the hierarchy all passed against a
component that froze on contact with a real browser. The new test gives
the elements a size, and fails with the exact error a user hits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:44:20 +02:00
0b8f874fa5 Let the export select on everything the data model holds
The export offered four criteria — unit, location, status, employment
type — while the employee record carries around twenty selectable
attributes. Anything else had to be filtered by hand in Excel afterwards,
which is how a payroll hand-off stops matching the application it came
from.

All of them are now filters: contract type, blue/white collar,
collective agreement, paygrade, internal/external, gender, company car
and its drivetrain, works council, lateral leadership, C-level, type of
long-term absence, weekday worked, dependents on file, and open ranges
for entry, exit, birth date and weekly hours. The unit filter covers
every level rather than only divisions, so a single department can be
selected without going the long way round.

They live in one table in lib/report-criteria.ts, which the filter panel
builds itself from, the parser validates against, and the query turns
into conditions. A new criterion is one entry there and nothing else —
and it cannot end up working in the report while being silently ignored
by the export.

The two export links and the saved-report config now carry the query
string through as it stands instead of listing the parameters they know
about. That enumeration was the actual defect: adding a filter meant
remembering three separate places, and forgetting one produced an export
that quietly disagreed with the figure on screen.

Validation is not housekeeping here. These values reach SQL comparisons
and the download filename, i.e. a Content-Disposition header; what is not
in the list does not get through.

The company car dropdown leaves the employee list. It is one of twenty
equals under Berichte now, where the selection can also be exported —
which was the point of asking in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:40:47 +02:00
f14f1cb8df Keep the org chart inside the page it is printed on
Six divisions, each with its departments beside it, ran off the edge of
the sheet. The cause was structural, not cosmetic: every level spread
horizontally, so width multiplied with depth. Six divisions times three
departments is eighteen boxes across a landscape A4 — about two
millimetres each, if they had fitted at all, which they did not. They
overlapped and were clipped at the margin.

Now only one level spreads sideways. The divisions stand in a row and
everything below them hangs lengthwise off a vertical line, so width is
the number of divisions and nothing else. Depth costs height instead,
and on a landscape page height is what there is to spare.

What still overhangs is scaled down as a whole. The sheet in the preview
now carries the print area's exact dimensions rather than growing with
its contents, so the fit is measured against the real page: what you see
is what the printer gets. If a sheet has to shrink below 55% to fit, it
says so and points at A3, instead of quietly producing something nobody
can read.

With names switched on, each department gets its own sheet — a whole
division with every name was never going to be legible on one page — and
long name lists set in two columns so the box grows sideways rather than
pushing the scale down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:40:31 +02:00
3151f32404 Print the org chart as an org chart, and ask first what to print
The chart on screen is an infinite canvas: you zoom in, drag around, and
look at one corner at a time. Paper has none of that. Printing the canvas
means scaling 850 people onto one sheet, which yields boxes two
millimetres wide — technically the whole company, practically nothing.

So the print view is rebuilt rather than shrunk, and it does two things
the canvas cannot.

It asks before it prints. Depth (bereiche, abteilungen, teams, or teams
with every name) and which divisions, each one selectable. Whoever needs
Produktion for a meeting gets two sheets instead of forty, and the page
count is on the button before anything reaches the printer.

And it draws the hierarchy as a hierarchy: boxes joined by connecting
lines, not a column of cards. Superior and subordinate are the entire
point of an org chart; a tidy list of the same units simply does not say
it. The lines come from borders on pseudo-elements, so the PDF keeps
them as vectors and they stay sharp when someone zooms in. Header
shading gets weaker with each level down, which survives the black-and-
white printer that most of these end up on.

Overview sheet first, then one sheet per selected division, each
carrying its own heading and headcount so page seven is still readable
on its own. A4 or A3, landscape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:03:54 +02:00
c23df08648 Ask for the optional things separately, and stop claiming numbers are issued
A round of interface corrections from use, plus one schema change behind
them.

The private email address is now optional. It was NOT NULL — the wrong
default for a private detail: someone without one had to invent one, and
invented data in a personnel file is worse than missing data. Both fields
are relabelled to say whose they are, "Private E-Mail" and "Private
Telefonnummer", because the company address does not exist until the person
starts. Uniqueness stays; several NULLs coexist in a Postgres unique index,
which is exactly what is wanted.

The summary step still promised that "Personalnummer und
Firmen-E-Mail-Adresse werden automatisch vergeben". Neither is true any
more. Removed rather than reworded — the step lists what was entered, and a
banner claiming otherwise is worse than no banner.

Dependents move into the wizard as step three, optional. They can only be
attached after the hire, because add_employee_dependent needs an id that
does not exist while the form is open, so they are collected in the draft
and written afterwards. That puts them outside the transaction the person is
created in: if one fails the person still exists, so the message names who
is missing instead of failing silently, and the SV number is checked in the
step rather than after.

The emergency contact gets its own step, second to last, and its
relationship is a dropdown of the common ones rather than free text —
otherwise "Gattin", "Ehefrau" and "Frau" end up side by side and nothing can
be counted. "Sonstige" is there because a closed list would otherwise be
presumptuous.

On the master-data tab it now sits below the dependents rather than above:
both are people around the employee, and this is the one you reach for in a
hurry.

Returning from a long absence: the choice read "unverändert", which made you
open the file to find out what you were agreeing to. It now reads "Wie vor
Abwesenheit (38,5 h)" with the hours actually worked, and the alternative is
"Reduziert" — whose hours field starts empty on purpose. A number already
filled in gets confirmed rather than read off the agreement it comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:47:04 +02:00
27e0e8a8ce Ask for someone's organisation on a day they actually have one
An employee starting 01.09. showed "Keine Führungskraft (Geschäftsführung)"
although their team has one — Josef Bauer, on chief position 60000752. The
reporting line was requested as of today, and today that person holds no
assignment, so om_reporting_lines() returned no row at all.

The database function was right; the caller asked the wrong question.

What made it look like a data problem rather than a date problem: the header
did show the unit and the position, because pickPlacements() falls back to
the next best assignment when none is current. Two notions of where someone
sits — one forgiving, one strict — sitting next to each other on the same
page.

orgAsOf() pulls the date into the employment: the first day for someone not
yet started, the last for someone who has left, today otherwise. Exit dates
are exclusive throughout the model, so the last working day is the day
before.

Anyone already gone had the same defect for the same reason, which is why
the rule covers both ends rather than special-casing the case that was
reported.

Verified against the live database: as of today no row, as of 2026-09-01 the
manager is Josef Bauer. Six unit tests over the boundaries, checked by
mutation — remove the future-entry branch and one fails.

Open positions still resolve as of today: they belong to the organisation,
not to the person whose file is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:23:21 +02:00
3926f1bb80 Load a whole organisation from a file, or none of it
Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.

Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.

Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.

Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:

  - weekly_hours is bound to employment type by a CHECK constraint: full time
    is exactly 38.5. The import reached the insert and was rolled back. Now
    it is a finding with a row number.
  - Titles are restricted to a fixed list by another CHECK. Same treatment.
  - setval() needs UPDATE on the sequence, which `usage, select` does not
    grant. Migration 20260803120000 adds it; until it is applied, an import
    containing people will fail at the last step and take itself back.

I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.

Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:40:33 +02:00
16b37244c8 Read an import file without guessing what it means
First half of the mass import: a file becomes named sheets with typed rows,
and every rule that could reject a row is stated in one place.

Nothing here touches a database. The parser turns bytes into sheets, the
schema says which columns exist, and validation reports findings — the
existing state is passed in as a parameter. That is what makes 36 tests
possible without a connection, and the rules are the part worth testing.

Three decisions where the easy choice would have been silent corruption:

  - A two-digit year is refused. "15.08.68" is 1968 as a birth date and 2068
    as a contract end, and any rule invented here creates people not yet
    born.
  - "31.02.2026" is refused. Date turns it into March 3rd without complaint.
  - An unrecognised value in a yes/no column is an error, not "no". Read the
    other way, a typo in "Betriebsrat" quietly removes someone's dismissal
    protection.

CSV is parsed rather than split. German Excel writes semicolons because the
comma is the decimal separator, so the delimiter is sniffed from the header;
a semicolon inside a quoted address would otherwise shift every following
column and import the row plausibly wrong. Quoted newlines, doubled quotes
and the byte-order mark Excel prepends are all handled — the last one makes
the first column read as "?Personalnummer", which is invisible in an editor.

Validation collects every finding instead of stopping at the first. With 800
rows that is the difference between correcting once and uploading eight
hundred times.

One rule earns its place from experience: a history event dated before the
entry it belongs to is refused here, with a row number, because the database
refuses it too — mid-insert, without one.

My own slip, caught by the type checker: `a ?? b ? c : d` does not mean what
it looks like; ?? binds tighter than the conditional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:27:49 +02:00
4d8e3f7154 Restore the shapes the application was written against
The app runs against a real database for the first time since the port, and
two things were broken. Both were invisible to typecheck, lint, 192 tests
and the build.

Sign-in looped. Auth.js created the session and app_upsert_user() adopted
the existing profiles id correctly — the row was in app_users, right id and
all — but the proxy builds its own Auth.js instance from lib/auth/config.ts
alone, and the session callback that copies token.uid onto session.user.id
lived in auth.ts. So the proxy saw a session without an id, treated every
signed-in user as signed out, and sent them back to /login. Click, flash,
login page: from the outside it looked like the button did nothing.

The callback moves to the config both instances share. auth.ts now spreads
the base callbacks instead of replacing them, which is the mistake that
would reintroduce this.

The proxy test did not catch it because its fixture hands the handler a
session that already has user.id — it tested the routing, not the shape
Auth.js actually produces.

Then the dashboard crashed on a.date.localeCompare. PostgREST returned JSON:
a `date` arrived as "2026-08-03", a `numeric` as a number, and that is what
lib/supabase/types.ts declares and what every sort, every date comparison
and every status derivation assumes. The pg driver does the opposite — Date
object and string respectively. The declarations stayed true to what the
code believes; only the runtime value changed, which is why nothing flagged
it.

The driver is configured back to the declared shapes in lib/db/pool.ts,
rather than rewriting 49 call sites. That also removes a timezone hazard:
`date` is a calendar day, and as a Date object it acquires midnight in the
server's zone — a birth date would shift by a day in Austria, always. The
same class of bug as in the seed.

int8 stays a string on purpose: it only comes from count() and is read
through Number() everywhere; parsed as a number it would quietly lose
precision past 2^53.

A missing sign-in error now reaches the server log. Auth.js was failing
silently — a 302 back to /login and nothing to read. That was its own
defect, and it is the reason the first diagnosis took as long as it did.

Verified against the live database: all six pages render, 797 active of
852 records, 744.4 FTE, and a detail page shows birth date 15.08.1968
against SV number 7960 150868 — the digits agree, so no day has shifted.
Both new tests were checked by mutation: remove the fix and they fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:11:34 +02:00
2ba9b37aa7 Hand the front door to Entra, and keep the keys out of the build
Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.

lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.

That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.

The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.

Two things only came out by running it:

  - `export const proxy = auth(…)` is not a function declaration, so
    Next.js never found it and every request 404'd. `next build` reported
    success and listed the proxy. In the function config form auth() also
    returns the handler as a promise, so it needs an await. The proxy test
    now mocks it as a promise for that reason — a friendlier mock would
    let the same bug back in.

  - A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
    /common/, and the redirect really did go there. That would let any
    Microsoft account sign in, including a private one, and it would never
    look broken. It now refuses to start in production.

Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.

Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:57:32 +02:00
b3a0af2b8f Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.

Was dabei ersatzlos verschwindet:

  - fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
    1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
    Am direkten Zugang ist eine Abfrage eine Abfrage.
  - sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
    Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
    als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
    abgesichert, sondern weg.
  - lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
    genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
    Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
    selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.

Nebenbei besser geworden, weil der direkte Zugang es erlaubt:

  - Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
    Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
    Lesestand statt in fünf unabhängigen Anfragen.
  - Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
    eingebetteten Ressource mit !inner — eine Person mit mehreren
    Zuordnungen über die Zeit erschien dort mehrfach.
  - Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
    oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
    dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
  - Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
  - Namen an Ereigniszeilen kommen aus einem Join statt aus einem
    Nachschlag, der ausserhalb der Transaktion lag.

Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.

Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.

Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
2026-07-31 08:45:26 +02:00
a66263a96e Put the session context under the app's own control
Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".

Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.

Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.

Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.

Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:

  - Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
    withUser() — und das öffnet immer eine Transaktion.
  - set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
    bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
    Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
  - Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
    ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
    erzeugt zwei Fehler.
  - Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
    dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
    muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.

tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.

Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.

Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
2026-07-30 19:01:39 +02:00
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
35f17858d8 Build the org tree in the OM model as a tested pure function
Constructing the tree is where parent links, chief positions and number
ranges get wired up wrongly without anyone noticing — a team under the wrong
Bereich looks perfectly plausible in the org chart. So the construction is a
pure function taking an id generator, and the checks that matter are asserted
rather than eyeballed: exactly one root, every unit's parent of the expected
type, every unit reaching the root, one chief position per unit, unique
numbers in the right ranges, and every position pointing at a real unit and
a real job.

Also introduces the job catalogue this model needs. job_title was free text
per person, so "Schlosser:in" and "Schlosser" could coexist and no breakdown
by occupation was possible; jobs are now deduplicated by title and shared
across positions.

Every Abteilung gets a chief position, which is the level the old three-table
model had no room for.

Correction to the previous commit message: it claimed the cut-over could
follow later while the old tables kept working. It cannot. The legacy
resolve_manager_for() finds a Bereichsleitung by "division_id = X and
team_id is null and org_level = 1", and an Abteilungsleitung satisfies the
same predicate — its LIMIT 1 would then pick one of the two arbitrarily. The
two models cannot both be correct once the new level is populated, so the
remaining work is a single cut across the 11 RPCs that read the legacy
columns, not a gradual migration.
2026-07-27 12:52:02 +02:00