Commit Graph

60 Commits

Author SHA1 Message Date
8b7e32f14a Hay-Grade auch in "Daten aendern"
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m35s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Die Befoerderung konnte ihn schon setzen, "Daten aendern" nicht -- weder das
Formular noch change_employee_data kannten das Feld. Damit war eine Einstufung
nur ueber den Weg "Befoerderung" zu aendern, und eine Richtigstellung ("da
stand von Anfang an die falsche Stufe") ist keine Befoerderung: sie soll keine
Planstelle wechseln und kein Ereignis in der Historie hinterlassen.

Das Feld gehoert zur Gruppe contract und ist damit datiert -- eine Umstufung
gilt ab einem Tag. Es kann also in pending_org_changes landen, und deshalb
steht es auch im Nachtlauf. Genau diese zweite Stelle ist hier schon einmal
vergessen worden: die Gruppe role fehlte dort monatelang, und eine datierte
Aenderung wurde als applied vermerkt, ohne etwas zu tun. Drittens
app_feld_karte, sonst waere der Eintrag in der Historie nicht richtigstellbar.

Alle drei Funktionen werden aus der laufenden Definition gelesen und an genau
einem Anker ergaenzt, nicht aus einer Datei kopiert.

Der Test zur Feldkarte liest jetzt alle Migrationen statt einer bestimmten. Der
feste Dateiname darin trug den Vermerk "die zuletzt gueltige Fassung" und war
schon zwei Migrationen spaeter falsch -- und ein Teil der Feldkarte kommt
inzwischen ohnehin aus einer Punktaenderung statt aus einer vollstaendigen
Fassung.
2026-09-28 15:06:58 +02:00
779beb4478 Hay-Grade statt Verwendungsgruppe A--F
A--F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene Stufen
mit erfundenen Beschreibungen. Der Kunde bewertet nach Hay und hat die Liste
geschickt -- 13 Stufen plus einen Generic Grade.

Dieselbe Spalte fuellt im Cornerstone-Extrakt die Grade ID. Solange dort A--F
steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
auffaellt: das Zielsystem kennt diese Kennungen nicht.

Der Typ heisst weiter paygrade_type, ist aber jetzt eine Domain ueber text mit
CHECK statt eines Aufzaehlungstyps. Ein Enum laesst sich nicht umschreiben --
Werte entfernen geht gar nicht, und add value darf im selben Vorgang, der den
neuen Wert schreibt, nicht benutzt werden. Wichtiger: die rund zehn
SQL-Funktionen, die den Wert nach paygrade_type umwandeln, bleiben unveraendert
gueltig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
haben, aus einer veralteten Vorlage zu kopieren.

Zwei Funktionen muessen doch angefasst werden, beide per Punktaenderung an der
laufenden Definition statt per Kopie aus einer Datei: der Vorgabewert B in
hire_employee und die Beschriftung im Protokoll von promote_employee.

Der Bestand bekommt den Generic Grade. Aus A--F liesse sich kein Hay-Grade
ableiten: andere Einteilung, andere Anzahl. Geraten saehe im Extrakt genauso
aus wie erhoben.
2026-09-28 11:13:41 +02:00
c3e19606e2 Die Cornerstone-ID als eigenes, freiwilliges Feld
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m58s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m24s
Cornerstone fuehrt jede Person unter einer eigenen Kennung. Der Export
dorthin trug in "User ID" und "Username" bisher die Alpenwerk-UUID --
richtig, solange es nichts Besseres gab, aber nicht die Kennung, unter
der Cornerstone die Person kennt. Jetzt steht dort diese Spalte.

Freiwillig, weil die Zuordnungstabelle des Kunden fuer 434 der 784
Personen keine Kennung liefert. Eindeutig, weil eine Kennung genau einer
Person gehoert. Als Text, weil fuehrende Nullen in einer numerischen
Spalte verlorengingen.

Ohne hinterlegte Kennung bleiben User ID und Username **leer**. Ein
Rueckfall auf die UUID braechte zwei Kennungsarten in eine Datei, ohne
dass es auffiele, und legte in Cornerstone eine zweite Person neben der
bestehenden an. Eine fehlende Angabe soll fehlen; dafuer gibt es einen
eigenen Test.

Fuenf Funktionen mussten mit -- dieselbe Liste und derselbe Grund wie bei
der Firmen-E-Mail: hire_employee und rehire_employee teilen sich den
Schritt "Person", change_employee_data macht das Feld aenderbar,
apply_due_pending_changes sorgt dafuer, dass eine datierte Aenderung
nicht verfaellt, app_feld_karte haelt den Eintrag in der Historie
richtigstellbar. Die Migration ist wieder erzeugt, nicht abgeschrieben,
und prueft jede der fuenf einzeln.

Erfasst wird das Feld in der Akte, in "Daten aendern", bei Einstellung
und Wiedereintritt sowie ueber den Massenimport; es steht im
Mitarbeiterexport und fuellt im Cornerstone-Export User ID und Username.
2026-09-28 10:13:52 +02:00
e5aa527473 Angehoerige berichtigen, und beide Abschnitte sehen gleich aus
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m26s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m15s
Angehoerige liessen sich bisher nur anlegen und entfernen. Ein Tippfehler
im Namen war nur zu beheben, indem man die Person loeschte und neu
anlegte -- zwei Eintraege in der Akte fuer eine Korrektur, und der erste
sagte "entfernt", was nicht stimmte. Jetzt steht in jeder Zeile ein Stift,
wie beim Notfallkontakt.

update_employee_dependent kommt bewusst **ohne** Stichtag. Hinzufuegen und
Entfernen tragen einen, dort passiert etwas zu einem Zeitpunkt. Eine
Berichtigung nicht: der Wert war schon vorher falsch, und ein "wirksam ab"
hiesse, die Person habe bis dahin anders geheissen. Der Eintrag in der
Akte nennt deshalb Vorher und Nachher statt eines Datums.

In "Daten aendern" standen fuer den Notfallkontakt drei Eingabefelder,
waehrend die Angehoerigen darueber als Tabelle mit Knoepfen erscheinen --
zwei Bauformen fuer denselben Zweck, direkt untereinander. Dort steht
jetzt derselbe Abschnitt wie im Reiter "Stammdaten"; beide laufen ueber
das "Wirksam ab" des Formulars, wie die Angehoerigen es schon taten.

Damit schreibt auch nur noch eine Stelle diese drei Spalten. Vorher
schickte das Formular sie zusaetzlich im eigenen Aufruf mit.
2026-09-23 20:03:43 +02:00
37650ee9a4 Befoerderung wahlweise auf eine neue Planstelle -- und der Nachtlauf, der sie ausfuehrt
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m9s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Bei "Befoerdern" steht jetzt die Wahl: selbe Planstelle oder eine neue.
Bei "neu" wird sie aus den freien gewaehlt, dieselbe Auswahl wie die
Zielplanstelle bei der Versetzung, und die Stelle schlaegt ihre
Taetigkeit als neue Bezeichnung vor -- nur als Vorschlag, denn
job_title darf von der Planstelle abweichen.

Die Zielstelle muss frei sein, mit derselben Meldung wie bei der
Versetzung. Ausgenommen ist die eigene: darauf sitzt die Person schon,
und "bereits besetzt" waere eine Falschaussage ueber sie selbst.

Dabei ist ein aelterer Fehler aufgefallen. transfer_employee legt fuer
ein kuenftiges Datum einen Eintrag in pending_org_changes mit
target_position_id an. Der Nachtlauf hat die Besetzung dort zur
Umstellung auf Planstellen (20260727120200) umgehaengt; zuletzt stand in
diesem Zweig nur noch

    update employees set job_title = coalesce(payload->>'new_title', ...)

also die Fassung von *vor* jener Umstellung -- und new_title kommt in
diesem payload gar nicht vor. Eine auf spaeter datierte Versetzung wurde
am Stichtag auf "applied" gesetzt und bewegte niemanden: die Person blieb
auf ihrer alten Planstelle, im Organigramm unveraendert, ohne Meldung.
Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
neu geschrieben -- dieselbe Falle wie bei der Gruppe `role` und bei der
Anmerkung zum Austritt.

Beide Zweige haengen die Besetzung jetzt um, und die Selbstpruefung
zaehlt nach, dass es zwei sind. Nebenbei bekommt promote_employee den
festen search_path und app_current_user_id() statt auth.uid().
2026-09-23 18:41:41 +02:00
93cb1c1699 Den Notfallkontakt wie die Angehoerigen darstellen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m39s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Beide Abschnitte stehen im selben Reiter und zeigen dasselbe: Personen im
Umfeld. Die Angehoerigen standen in einer Tabelle, der Notfallkontakt in
einer Beschreibungsliste -- zwei Baustile untereinander, ohne dass ein
Unterschied in der Sache dahintersteht.

Jetzt dieselbe Tabelle mit denselben Spaltenkoepfen, und Aendern und
Entfernen als Symbole in der Zeile, wie dort das Entfernen. Der Knopf
"+ Hinzufuegen" steht nur, solange kein Kontakt hinterlegt ist: es gibt
genau einen, und ein Knopf daneben verspraeche eine Liste. Die
Telefonnummer bleibt waehlbar.
2026-09-23 18:29:53 +02:00
db02fb4762 Elf Punkte aus der Rueckmeldung, und die verlorene Anmerkung
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m23s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m18s
Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel
"Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte
(Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn.
"Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der
Behinderung und der Erklaertext ueber dem Honestly-Report sind weg.
"Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt
ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und
ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler.

Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine
Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern"
wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im
Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte
Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse.

"Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer
"Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt.
Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den
keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe
Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine
Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden
Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still
auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben
bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige.

Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends.
Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis
20260814100000 stand sie in der Beschreibung des Ereignisses; beim
Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration
fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht
jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung
faengt den naechsten Verlust ab.
2026-09-23 16:01:05 +02:00
5e35d6bf41 Eine geleerte freiwillige Angabe ist null, nicht die leere Zeichenkette
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m49s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Beim Speichern in "Daten aendern" brach es ab, sobald bei einer zweiten
Person das Feld "E-Mail (privat)" leer blieb:
duplicate key value violates unique constraint "employees_email_key".

email war bis 20260811140000 NOT NULL -- jede Person hatte eine, das Feld
war nie leer. Seither ist die Angabe freiwillig, aber
change_employee_data schrieb weiter `coalesce(v_person->>'email', email)`.
Ein leeres Formularfeld kommt als "" an, und "" ist nicht null: es landete
als leere Zeichenkette in der Spalte. Beim ersten Mal ging das gut, beim
zweiten schlug der eindeutige Index zu -- "" ist gleich "", waehrend null
nie gleich null ist.

Aufgefallen ist es erst jetzt, weil die Spieldaten durchweg Adressen
trugen. Die 784 uebernommenen Personen tragen keine.

Alle freiwilligen Textfelder bekommen deshalb dieselbe Form wie der
Notfallkontakt und die Firmen-E-Mail: `case when ? then nullif(…, '')`.
Fehlt der Schluessel, bleibt der alte Wert; steht er leer da, wird das
Feld geleert -- die Absicht, die jemand ausdrueckt, wenn er eine Angabe
herausloescht. first_name, last_name, gender, birth_date und nationality
bleiben ausgenommen, sie sind NOT NULL.

Der Nachtlauf bekommt dieselbe Behandlung, sonst liefe eine auf spaeter
datierte Aenderung in denselben Index -- um drei Uhr frueh und ohne
jemanden, dem die Meldung angezeigt wuerde. Was bereits als "" in der
Datenbank steht, raeumt die Migration auf; sonst blockierte diese eine
Zeile weiterhin jede weitere Person ohne Adresse.
2026-09-23 15:42:43 +02:00
948ddf5c70 Verhaeltnis als Auswahlliste, und im Organigramm keine Vakanz ganz oben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m41s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m16s
Zwei Nachtraege zur Rueckmeldung des Kunden.

Das Fenster fuer den Notfallkontakt hatte ein Freitextfeld fuer das
Verhaeltnis, waehrend der Einstellungsassistent und "Daten aendern"
dieselbe Auswahlliste fuehren (EMERGENCY_RELATIONS). Freitext hiesse,
dass "Gattin", "Ehefrau" und "Frau" nebeneinander stehen und sich nicht
auswerten lassen -- genau der Grund, aus dem die Liste ueberhaupt
existiert. Jetzt fuehren alle drei Stellen dieselbe.

Und "Leitung vakant" stand im Organigramm weiterhin bei der obersten
Einheit. Entfernt wurde es zuletzt nur im Druck; die Ansicht am
Bildschirm hat ihre eigene Beschriftung. Die Gesellschaft wird nicht
gefuehrt, sondern ist das Ganze -- der Vermerk las sich dort wie eine
offene Stelle und faerbte den obersten Knoten obendrein als Vakanz ein.
Ueberall sonst bleibt er.
2026-09-23 12:58:57 +02:00
57acbfae9d Die Firmen-E-Mail als eigenes, freiwilliges Feld
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m27s
employees.email ist die private Adresse (20260811140000). Sie taugt nicht
als Dienstadresse und darf auch nicht als solche benutzt werden: der
Honestly-Export geht an einen fremden Anbieter, der damit im Namen des
Arbeitgebers einlaedt. Die Spalte "Email" stand dort deshalb seit jeher
leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im
Datenmodell fehlt. Jetzt gibt es sie, und die Spalte fuellt sich.

Eindeutig, aber freiwillig -- mehrere Personen ohne Adresse stoeren den
Index nicht, weil null nie gleich null ist. Geschrieben wird ueber
`case when ? then nullif` statt `coalesce`: eine Dienstadresse muss sich
auch wieder entfernen lassen.

Vier SQL-Funktionen mussten mit, weil `create or replace` die ganze
Fassung ersetzt und ein ausgelassenes Feld dort still verschwindet:
hire_employee und rehire_employee (beide teilen sich den Schritt
"Person" -- das Formular haette das Feld gezeigt und den Wert
weggeworfen), change_employee_data (sonst nicht aenderbar),
apply_due_pending_changes (sonst verfiele eine auf spaeter datierte
Aenderung) und die Feldkarte (sonst waere der Eintrag in der Historie
nicht korrigierbar). Die Selbstpruefung am Ende prueft jede einzeln.
2026-09-23 10:52:26 +02:00
1816ab68ad Den Notfallkontakt an Ort und Stelle anlegen statt ueber "Daten aendern"
Bei den Angehoerigen steht ein "+ Hinzufuegen", beim Notfallkontakt stand
nichts: der einzige Weg zu einer Telefonnummer fuehrte ueber ein Formular
ueber saemtliche Stammdaten. Jetzt steht der Knopf an derselben Stelle,
mit einem Fenster fuer Name, Telefonnummer und Verhaeltnis; ist ein
Kontakt hinterlegt, heisst er "Aendern" und daneben laesst er sich
entfernen.

Geschrieben wird ueber dieselbe Funktion wie in "Daten aendern" --
change_employee_data mit nur den drei Feldern. Der Eintrag landet damit
in der Historie und im Protokoll wie jede andere Stammdatenaenderung. Die
Datenbank fasst nur die Felder an, die im Aufruf stehen: sie prueft auf
das Vorhandensein des Schluessels, nicht auf seinen Wert (Migration
20260917120000, Zeilen 209-211). Adresse, E-Mail und der Rest bleiben
unberuehrt.
2026-09-23 10:41:24 +02:00
8bb80d3bd2 Der Wiedereintritt oeffnet den Assistenten, vorbefuellt
Aus dem Gespraech vom 17.09.2026. Migration 20260917130000.

Der kleine Dialog fragte Datum und Planstelle und liess alles andere stehen,
wie es beim Austritt war. Nach zwei Jahren Abwesenheit ist das selten noch
richtig — Anschrift, Wochenstunden, Kollektivvertrag, oft auch der Name. Wer
es bemerkte, musste erst wiedereinstellen und danach "Daten aendern"
oeffnen: zwei Vorgaenge fuer einen, und in der Akte stand dann eine
Vertragsaenderung am Tag des Wiedereintritts, die niemand vorgenommen hat.

rehire_employee nimmt jetzt den ganzen Satz entgegen und schreibt ihn in
einer Transaktion. Erst einstellen und dann aendern waeren zwei
Transaktionen, und scheitert die zweite, steht die Person wieder im Dienst —
mit den Daten von damals und ohne dass es jemand merkt.

Jedes Feld mit coalesce: fehlt ein Schluessel, bleibt der bestehende Wert.
Das haelt den schlanken Aufruf am Leben und ist zugleich die Bedingung
dafuer, dass der Assistent nur schickt, was er auch zeigt — Anschrift,
Staatsbuergerschaft und Aufenthaltstitel fragt er naemlich nicht, so wenig
wie bei einer Neueinstellung.

Die Planstelle und das Eintrittsdatum sind bewusst leer: die alte Stelle
kann besetzt oder entfallen sein, und ein vorbelegter Platz, den es so nicht
mehr gibt, waere schlimmer als ein leeres Feld — er sieht nach einer Antwort
aus. Dazu die Pruefungen der Neueinstellung, die hier fehlten: existiert die
Planstelle, gilt sie zum Datum, ist sie frei.

Die Personalnummer steht fest und wird nur gezeigt. Sie zu pruefen faende
zwangslaeufig einen Treffer — die Person selbst — und sperrte das Formular
mit einer Meldung, die stimmt und trotzdem in die Irre fuehrt.

Ein eigenes Bauteil statt eines Schalters im HireWizard: kein Entwurf zu
speichern, keine Angehoerigen anzulegen (die stehen schon in der Akte),
keine Nummer zu pruefen, andere Funktion am Ende. Geteilt werden die
Schritte, und das ist der Teil, der wirklich geteilt gehoert. RehirePanel
ist damit weg.
2026-09-17 12:16:46 +02:00
6ab78d4b27 Uebernahme: extern auf intern ist ein eigenes Ereignis
Aus dem Gespraech vom 17.09.2026. Migrationen 20260917110000 (Enum-Wert)
und 20260917120000 (Logik) — getrennt, weil ein in derselben Transaktion
angelegter Enum-Wert dort noch nicht benutzt werden darf.

Die Besetzungsart (extern/intern) wurde bisher nur bei der Neueinstellung
gesetzt und war danach unerreichbar. Eine Angabe, die mit dem ersten Tag
erstarrt, obwohl gerade ihr Wechsel der Vorgang ist, um den es geht. Sie
steht jetzt in "Daten aendern".

Wechselt sie von Extern auf Intern, entsteht das Ereignis "Uebernahme" — in
der Personalakte und im Protokoll, und im Berichtemanager als Ereignistyp
auswertbar. Der umgekehrte Weg bekommt ausdruecklich keines: eine Uebernahme
zurueckzunehmen gibt es fachlich nicht.

Zwei Eintraege und nicht einer: die Vertragsaenderung haelt fest, dass ein
Feld sich geaendert hat und worauf (und laesst sich darueber zuruecknehmen),
das Ereignis, dass dieser Wechsel eine Uebernahme war. Nur das zweite laesst
sich zaehlen.

Eine auf spaeter datierte Uebernahme erzeugt das Ereignis sofort mit
pending_id, wie die Vertragsaenderung daneben; der Nachtlauf schreibt am
Stichtag nur noch die Spalte. Die Selbstpruefung haelt fest, dass er kein
zweites Ereignis schreibt — sonst staende die Uebernahme doppelt in der Akte
und jede Auswertung zaehlte sie zweimal.

Im Assistenten erscheint das Feld nicht: dort steht die Besetzungsart im
Schritt "Position", und zweimal danach zu fragen waere eine Einladung, zwei
verschiedene Antworten zu geben.
2026-09-17 12:10:50 +02:00
9ded472d25 Freiwillig oder unfreiwillig wird erhoben, nicht abgeleitet
Migration 20260917100000. Ruecknahme einer eigenen Entscheidung, nach der
Erklaerung des Kunden am 17.09.2026.

Wir hatten den Anstoss aus der Beendigungsart abgeleitet — Kuendigung AN
gilt als freiwillig, Kuendigung AG als unfreiwillig. Das schien sauberer,
weil es zwei Felder ausschliesst, die einander widersprechen koennen.

In Oesterreich stimmt es nicht. Die einvernehmliche Aufloesung ist hier der
Regelfall und sagt ueber den Anstoss nichts aus: sie kann von der Person
ausgehen ("ich moechte kuendigen", worauf einvernehmlich aufgeloest wird,
damit das AMS zahlt) oder vom Dienstgeber ("ich will die Trennung, dafuer
gibt es eine Abfindung"). Dieselbe Beendigungsart, zwei gegensaetzliche
Antworten — und das ist genau die Unterscheidung, auf die es bei einer
Fluktuationsanalyse ankommt. Die Ableitung haette die Haelfte der Faelle
still falsch einsortiert.

Im Formular steht jetzt die Beendigungsart oben mit allen Werten, darunter
"Freiwillig oder unfreiwillig". Keine der beiden schraenkt die andere ein.
Der abgeleitete Hinweis unter der Beendigungsart ist weg — er erschien von
selbst und sah aus wie ein Fehler des Formulars.

Die Angabe ist freiwillig: der Bestand traegt sie nicht, und ein
Befristungsablauf geschieht auf niemandes Betreiben. Ob sie fuer gewoehnliche
Austritte Pflicht werden soll, ist eine Frage an den Kunden.

rehire_employee raeumt sie mit dem Austritt weg. Die Funktion ist dabei
ausgeschrieben worden; die Selbstpruefung haelt fest, dass die beiden
Umstellungen, die sie schon hinter sich hatte (app_current_user_id statt
auth.uid, fester search_path), dabei nicht verlorengehen — genau das ist der
Fehler, den ein create-or-replace aus einer alten Vorlage leise macht.
2026-09-17 12:04:53 +02:00
4165f3f8a1 Der Wiedereintritt war da, nur nicht erreichbar
Aus dem Gespraech vom 17.09.2026.

Der Kunde suchte den Knopf "Wiedereintritt" an einer ausgetretenen Person
und hielt ihn fuer verschwunden — "ich dachte eigentlich, das ist schon
implementiert, das war mal drin". Er war drin. Er hing nur an
employee.status, und diese Spalte haengt nach: an einer Person, deren
Austritt erfasst und inzwischen vollzogen war, stand dort weiter "Aktiv".

Das ist mein Fehler beim Statusfix. Umgestellt waren dort `isActive` und
`canEditData` — die fuenf einzelnen Abfragen in derselben Datei blieben
stehen, dazu je eine in KarenzPanel und TerminatePanel. Jetzt liest keine
mehr die Spalte; die beiden Panels bekommen den abgeleiteten Status
uebergeben, statt ihn sich selbst aus der Zeile zu holen.

Betroffen war ausser dem Wiedereintritt auch: welcher Austritts-Knopf
erscheint ("Nicht angetreten" statt "Austritt"), die Beschriftung der
Abwesenheit, die Vorbelegung der Beendigungsart und ob die Zugehoerigkeit
angezeigt wird.

Dazu: die Niederlassung ist aus dem Reiter Organisation wieder raus. Sie
stand dort als unsere Auslegung von Anforderung 8; der Kunde hat sie im
Gespraech gestrichen ("nimm's mal hier raus"). Was mit der Anforderung
gemeint war, bleibt offen.
2026-09-17 11:50:26 +02:00
9ad2954970 Anmerkungen vom 16.09.: Uebersicht gegliedert, Personalnummer prueft frueher
1) Die Kacheln stehen jetzt in drei Gruppen — Personalstand,
Personalbewegung, Recruiting & Vakanzen — in der Reihenfolge aus dem Entwurf
des Kunden. Acht Zahlen nebeneinander sind acht Zahlen; sie beantworten aber
drei verschiedene Fragen, und ohne Ueberschrift muss man jede Beschriftung
einzeln lesen, um das herauszufinden. "Aktives Dienstverhaeltnis" steht
vorn: es ist die Bezugsgroesse fast jeder Personalkennzahl.

2) Der Namensfilter in "Anstehend" ist jetzt immer da. Die Schwelle "erst ab
neun Eintraegen" war in der Bedienung falsch — das Feld erschien bei 180
Tagen und verschwand bei 30, und ein Bedienelement, das je nach Zeitraum da
ist oder nicht, wirkt wie ein Fehler.

3b) Der Filter heisst jetzt "Aktives Dienstverhaeltnis (Aktiv +
Langzeitabwesenheit)" — derselbe Name wie die Kachel, die dorthin verlinkt.

6) Die Personalnummer wird gegen die Datenbank geprueft, waehrend sie
eingetippt wird, und nennt bei einem Treffer die Person, die sie schon hat.
hire_employee weist sie weiterhin ab — das bleibt die verbindliche Pruefung,
denn zwischen Frage und Anlegen kann jemand anderes dieselbe Nummer
vergeben. Nur kam diese Abweisung bisher nach sechs Schritten Eingabe, und
das Feld steht im ersten Schritt.

Gemerkt wird dabei die gepruefte *Nummer* samt Ergebnis, nicht ein Ja/Nein:
so ist die Sperre eine Ableitung aus dem, was im Feld steht, und es gibt
keinen Zustand, dessen Zuruecksetzen man vergessen koennte.

Zu 4) geprueft, nichts geaendert: die FTE-Kachel rechnet bereits Summe der
Wochenstunden der heute Aktiven durch 38,5. Der Berichtemanager rechnet
dieselbe Formel, nur als Summe der Einzelquotienten geschrieben.
2026-09-16 22:23:04 +02:00
4dc27bf212 Die Kachel verwies auf eine Adresse, die die Liste nicht lesen konnte
Die neue Kachel "Aktives Dienstverhaeltnis" verlinkte auf
?status=Aktiv&status=Karenz. Die Mitarbeiterliste liest den Parameter aber
als *eine* Zeichenkette und trennt selbst an Kommas — zweimal uebergeben
macht Next daraus ein Array, und `.split(",")` lief dagegen. Sichtbar war
nur "Diese Ansicht konnte nicht geladen werden".

Die Kachel schreibt jetzt status=Aktiv,Karenz. Dazu glaettet die Seite alle
ihre Parameter: eine Adresse kommt nicht nur aus der eigenen Anwendung, sie
steht in Lesezeichen und in E-Mails, und ?q=a&q=b haette sie genauso
gefaellt.

Zwei Anmerkungen von Max:

  * Die Reihenfolge der Wochentage wurde beim Speichern mitgenommen — "Mo,
    Di" und "Di, Mo" waren zwei Werte fuer dieselbe Aussage. Da
    change_employee_data die Arbeitstage als zusammengefuegte Zeichenkette
    vergleicht, erzeugte jedes Nachsehen und Wiederherstellen eine
    Vertragsaenderung in der Akte und einen Protokolleintrag — ueber nichts.
    Jetzt sortiert gespeichert (lib/wochentage.ts, an einer Stelle statt in
    vier Kopien), auch im Massenimport. Der Bestand richtet sich beim
    naechsten Speichern von selbst.
  * "Beguenstigt behindert" steht jetzt als eingerueckter Unterpunkt des
    Kuendigungsschutzes statt als eigener Block daneben. In der Datenbank
    bleiben es getrennte Felder, und das mit Absicht: eine Kopplung liesse
    jede Korrektur am Personenkreis scheitern, solange der Grad noch
    dransteht.
2026-09-16 22:06:38 +02:00
05d56bf3b9 Niederlassung in der Zuordnung, und die Rueckfragen zum Workshop
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m1s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Anforderung 8. "Zuordnung" liess mehr als eine Lesart zu; umgesetzt ist die,
die am wenigsten voraussetzt: in der Personalakte steht die Niederlassung
jetzt im Reiter Organisation neben Einheit und Kostenstelle. Vorher war sie
nur im Stammdatenblatt zu finden, bei der Privatadresse — dort sucht
niemand den Arbeitsort.

Anders als Einheit und Kostenstelle haengt sie an der Person und nicht an
der Planstelle. Sie steht deshalb auch dann da, wenn es keine laufende
Besetzung gibt.

Dazu docs/rueckfragen-workshop-2026-09.md: sieben Stellen, an denen die
Formulierung mehr als eine Lesart zuliess, mit der jeweils getroffenen
Entscheidung und ihrer Begruendung. Damit bleibt keine Auslegung
unausgesprochen, und jede laesst sich ohne Umbau umdrehen.
2026-09-15 22:58:25 +02:00
cf0ea51f47 Mitarbeiterart als zweite Achse neben der Beschaeftigtengruppe
Anforderung 9 aus dem Workshop. Migration 20260915140000.

Im Dokument standen die beiden untereinander:

    Arbeiter, Angestellte, Lehrlinge
    Standard / Praktikant / Geringfuegige Beschaeftigung / Altersteilzeit

Die erste Zeile gibt es schon als worker_type, Lehrling seit 20260910140000.
Die zweite ist eine andere Frage: nicht *als was* jemand angestellt ist,
sondern *in welcher Form*. Beide gelten gleichzeitig — ein Praktikant ist
Arbeiter oder Angestellter, nicht statt dessen. In eine Liste gepresst
muesste man sich fuer eine der Antworten entscheiden und verloere die andere.

NOT NULL mit Vorgabe "Standard": jede Person ist in irgendeiner Form
beschaeftigt, und "nicht erfasst" waere keine Aussage, sondern eine Luecke,
die sich durch jede Auswertung zieht. Der Bestand bekommt damit einen
sichtbaren, korrigierbaren Ausgangswert statt eines leeren Feldes.

Die vier Funktionen wurden vollstaendig neu geschrieben. Die Selbstpruefung
haelt deshalb auch die Felder der vorigen Migration fest: eines beim
Uebertragen zu verlieren waere ein Fehler, der nirgends auffiele — die
Oberflaeche schickte den Wert weiter, und die Funktion ignorierte ihn.
2026-09-15 22:52:44 +02:00
c25c16e372 Kuendigungsschutz bekommt einen Personenkreis, die Behinderung eigene Felder
Anforderungen 5 und 5a aus dem Workshop. Migration 20260915120000.

Bisher gab es ein Kennzeichen und ein Enddatum. Das beantwortet "darf hier
ohne Weiteres beendet werden?" — nicht aber, *warum* jemand geschuetzt ist,
und davon haengt ab, wer zustimmen muss. Nachzusehen war das nur im
Papierakt, also dort, wo unter Zeitdruck niemand nachsieht.

Zwoelf Personenkreise als CHECK auf text und nicht als Aufzaehlungstyp: die
Liste ist Rechtslage und aendert sich mit dem Gesetz, ein Typ liesse einen
zurueckgenommenen Wert fuer immer stehen. Die Oberflaeche liest dieselbe
Liste aus lib/kuendigungsschutz.ts; ein Test liest die Migration und haelt
beide gegeneinander, damit die begruendete Doppelung keine stille wird.

Die begueenstigte Behinderung bekommt Kennzeichen, Grad, Beginn und Ende —
vier Spalten und nicht eine zusammengesetzte, weil in Excel danach
gefiltert und summiert wird. Datenbankseitig sind sie *nicht* an den
Personenkreis gekettet: eine solche Bedingung scheiterte genau dann, wenn
jemand den Kreis korrigiert und der Grad noch dransteht. Die Oberflaeche
stellt den Zusammenhang her.

Dazu zwei Dinge, die auf dem Weg auffielen:

  * Der Nachtlauf wendete bei einer auf spaeter datierten Aenderung nur
    Person und Vertrag an — die ganze Gruppe "role" fiel weg. Betriebsrat,
    Dienstwagen, Kollektivvertrag, Arbeitstage, Teilzeit und
    Kuendigungsschutz wurden erfasst, in der Historie vermerkt, protokolliert
    und am Stichtag nicht geschrieben. Sichtbar wurde das nie. Die neuen
    Felder haetten den Fehler geerbt; er ist jetzt fuer alle behoben.
  * Der Mitarbeiter-Export filterte ohne Stichtag ueber die Spalte `status`,
    mit Stichtag ueber die Ableitung. Der Export nach "Ausgetreten" liess
    damit genau die Leute aus, die gerade ausgetreten sind.
2026-09-15 22:47:22 +02:00
8d0c9b4b65 Workshop-Anforderungen, erster Teil: was ohne Migration geht
Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der
Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der
Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei
neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben
beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert
— als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das
Dropdown im Formular schraenkt die Auswahl darunter ein.

Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes
Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite
zuzuschlagen wuerde jede Fluktuationsquote verfaelschen.

Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen.

Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg;
"Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines
zusteht, nicht ob es uebergeben wurde.

Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren
vorher nur ueber den Export erreichbar. Stammdatenaenderung und
Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder
geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das
Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt,
Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export.

Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht
dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet,
hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste
man selbst.
2026-09-15 22:35:47 +02:00
1cbed1a8f5 Der Status kommt aus den Daten, nicht aus der Spalte
Die Liste filterte ueber die Datumsspalten, beschriftete die Zeilen aber mit
employees.status. Sobald die Spalte nachhaengt, widersprechen sich die
beiden — und sie haengt regelmaessig nach: terminate_employee setzt sie nur,
wenn das Austrittsdatum nicht in der Zukunft liegt, und es gibt keinen Lauf,
der das spaeter nachzieht (Migration 20260814100000 sagt das selbst).

Beim Kunden waren beide Richtungen zu sehen. Der Filter "Ausgetreten" fand
48 Personen, von denen mehrere als "Aktiv" beschriftet waren; der Filter
"Geplant" zeigte Nichtantritte, deren Spalte laengst "Ausgetreten" trug.

StatusChip nimmt deshalb jetzt die Zeile und den Stichtag und leitet selbst
ab. Die Spalte laesst sich nicht mehr hineinreichen — die zweite Quelle ist
nicht bloss ungenutzt, es gibt sie an dieser Stelle nicht mehr.

Dazu drei Stellen, die an derselben Spalte hingen:

  * Die Akte entschied mit ihr ueber die Knoepfe. An einer Person, die seit
    zwei Wochen ausgetreten ist, stand "Austritt" weiter zur Verfuegung.
  * Die Sortierung nach Status ordnete nach einem Wert, der nirgends auf der
    Seite steht.
  * Die Karte "Anstehend" zaehlte kuenftige Eintritte und Rueckkehren ueber
    die Spalte und damit anders als die Liste, auf die sie verlinkt.

Und eine Klausel, die in der Ableitung fehlte: ein Nichtantritt traegt als
Austrittsdatum den Eintrittstag. Liegt der in der Zukunft, ist auch der
Austritt groesser als der Stichtag — die vorige Korrektur verglich nur gegen
den Stichtag und blieb damit wirkungslos. Endet ein Verhaeltnis nicht
spaeter, als es beginnt, gab es keinen Tag Beschaeftigung, zu keinem
Stichtag.
2026-09-15 22:16:30 +02:00
7a33e493b5 Die Historie bekommt ihre eigenen Farben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 10m59s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m0s
In "Letzte Aktivitaeten" stand "Eintritt" weiter auf Rosa, waehrend die Karte
daneben ihn laengst gruen zeigte. Dieselbe Ursache wie bei den
Anstehend-Chips, nur eine Ecke weiter: die Uebersicht zeigt Ereignisse aus
employee_history, holte ihre Farbe aber aus ACTION_CATEGORY — und das ist die
Sprache des Protokolls. Dort heisst es "Neueinstellung" und
"Wiedereinstellung", in der Historie "Eintritt" und "Wiedereintritt". Genau
diese zwei von elf standen nicht darin und fielen auf den neutralen Chip
zurueck; die uebrigen neun trafen zufaellig.

EVENT_CATEGORY ist jetzt die Zuordnung fuer die Historie, als
Record<HistoryEventType, …> und damit vollzaehlig: ein zwoelftes Ereignis
laesst der Typpruefer nicht durch, ohne dass jemand eine Farbe dafuer
bestimmt. Ein Nachschlagen mit Rueckfall haette auch dann wieder still etwas
Plausibles geliefert.

Betroffen war nicht nur die Uebersicht — der Historie-Reiter in der
Personalakte faerbte seine Chips und seine Filterknoepfe aus derselben
falschen Tabelle. Auch die sind umgestellt.

Die Punkte vor den Zeilen lagen in einer zweiten Tabelle in page.tsx und
sagten fuer "Eintritt" bereits gruen — Punkt und Chip derselben Zeile kamen
also aus zwei Verzeichnissen, von denen eines das falsche war. Beide leiten
jetzt aus EVENT_CATEGORY ab.

Die Rueckkehr ist dabei violett geworden, auch in der Historie: auf der
Uebersicht steht sie neben dem Eintritt, und zwei Gruentoene nebeneinander
sind keine zwei Dinge. ANSTEHEND_STYLES leitet fuer Eintritt, Austritt und
Rueckkehr aus derselben Tabelle ab — die beiden Karten koennen nicht mehr
auseinanderlaufen.

ACTION_CATEGORY behaelt seinen Rueckfall, und das bleibt richtig: die
Aktionen schreiben die SQL-Funktionen als freien Text, eine neue kann
jederzeit dazukommen, und ihr neutraler Chip ist dann eine ehrliche Aussage.
Fuer eine geschlossene Aufzaehlung war derselbe Rueckfall ein Fehler.

Acht Tests, aus EVENT_TYPE_LABELS abgeleitet statt abgeschrieben: dass jedes
Ereignis eine Farbe hat, dass keines den neutralen Chip bekommt, dass Punkt
und Chip derselben Zeile zusammenpassen und dass die beiden Karten der
Uebersicht dasselbe meinen.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:48:54 +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
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
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
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
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
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
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
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
4eba557121 Show every field the edit dialog can change
The master-data tab summarised where the edit dialog itemises. Titles were
collapsed into one line, street, postcode and town were fused into a single
"Adresse", and first and last name appeared only in the page header — so
checking a value meant opening the change dialog to see it, which puts you
inside a form when you only wanted to look.

The tab now mirrors the dialog's "Person" section field for field and in the
same order, personnel number included.

Two deliberate departures from a literal mirror:

  - Standort sits at the end rather than between Adresse and Land. It is the
    workplace, not part of the person's address, and next to the postal
    fields it reads as though it were.
  - The emergency contact keeps the separate block it got earlier today,
    with its phone number as a tel: link. In an emergency someone reaches
    for it in a hurry; it should not be one cell among fourteen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:33:28 +02:00
9d754359e0 Enter the personnel number, tell the two kinds of company car apart, record who to call
Three requests from use, one of which changes the schema's mind about
something.

The personnel number is no longer issued. It was GENERATED ALWAYS AS
IDENTITY, which refuses a supplied value outright — but it has to match Loga
and Interflex, and a number this application invents is unknown there, so the
same person ends up with two. Identity dropped, entered everywhere instead:
in the wizard, in the import, and validated against a duplicate with a
message that names the number.

Worth stating plainly: the column had no unique constraint. The identity
prevented collisions as a side effect, and once the value comes from outside
that side effect is gone. The constraint is the point now, and it was
missing.

Company cars distinguish Verbrenner from Elektro, tied to has_dienstwagen by
a CHECK so "E-KFZ" cannot appear against someone without a car. The list
filters on it — with, without, only electric, only combustion — which is the
question the report was really about; it was answerable before only through
an export and manual work.

Emergency contact is name, phone and relationship. Relationship stays free
text: the examples given — Gattin/Gatte, Schwester/Bruder, Freund — are not
a list that closes without telling someone their arrangement does not count.
Name and phone are all-or-nothing, in the database and in both forms: a name
without a number helps nobody, a number without a name does not say who
answers.

Two mistakes of mine on the way, both caught by checks I had written into
the migrations rather than by me:

  - The first CHECK on the car type would have permitted exactly the case it
    was written against. `art in (…)` yields NULL rather than false when the
    column is null, and a CHECK counts NULL as satisfied. It needs an
    explicit `is not null` in front.
  - The constraint was added before the backfill, so it rejected every
    existing row with a car.

Existing cars are recorded as Verbrenner, which is an assumption — but a
visible one: "Elektro" appears nowhere nobody confirmed it.

hire_employee and change_employee_data both had to learn the new columns.
They name their columns one by one, and what is missing there is dropped in
silence — the interface would have collected the fields and thrown them
away, which is what happened to the email address this morning.

Verified against the live database, all rolled back: a hire without a number
is refused, a duplicate is refused naming it, a freely chosen one goes
through; E-KFZ plus contact arrive intact; a contact without a phone is
refused. A change records both, with before and after in the audit detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:27:00 +02:00
f28fd2da60 Let a rehire choose the position, and make it work at all
Clicking "Wiedereinstellen" could never succeed. rehire_employee has always
demanded a position and refuses without one, but the panel offered only a
date and sent only a date — so every rehire ended on an error the dialog
gave no way to fix.

The panel now picks from the open positions, the same list and layout the
transfer panel uses, and warns before submitting when the date falls outside
the chosen position's validity. The old position is deliberately not a
silent default: it may since have been filled, ended, or gone.

Behind that sat a second fault, hidden by the first: the status assignment

    status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end

is text, and the column is employment_status. Postgres refuses that outright,
so the function would have failed even with a position. It surfaced only once
the earlier check stopped firing — the same pattern as hire_employee this
morning, where three faults sat in a queue.

rehire_employee also placed people without checking anything. It now applies
the rule from 20260810100000: the date must lie in the position's validity,
and no assignment may still stand. A rehire could otherwise land on an
occupied position and be caught by the partial index, with a message that
explains nothing.

My first verification of the cast was wrong and passed a broken state:
plpgsql converts silently when assigning to a variable, so the probe proved
nothing. Redone as an UPDATE against a column, which is the case that fails.

Verified end to end against the live database, rolled back: Stefan Egger
returns as Aktiv on a free position, with the assignment and the
Wiedereintritt entry. Without a position, on an occupied one, and on one not
yet valid, it is refused — each with its own message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:29:08 +02:00