Commit Graph

97 Commits

Author SHA1 Message Date
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
410acfe01f Cornerstone Report: system values, not display names
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m34s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Reworked against the load spec and the 27.09. test file. The values I
had guessed were the German display names, which is the first entry on
the list of errors from earlier loads: Cornerstone answers "ungültiger
Wert" and rejects the whole row, followed by "Alle abhängigen Felder
müssen gültig sein" as a follow-on.

  Status            Aktiv/Inaktiv    -> Active/Inactive
  Employment Status Arbeitend/...    -> Working/On Leave/Terminated
  User Type         Mitarbeiter      -> Employee
  Time Zone         CET              -> empty

The time zone is the second entry on that list: only a portal time zone
id is valid, an abbreviation gives "Zeitzonencode nicht eindeutig".
Empty means the portal or the OU decides.

Division ID is the GUID from the test file, not a name. Termination
fields and Leave Reason are filled only when the employment status
carries them -- a reason without a termination is an invalid state for
the load, not extra information.

Four fields now stay empty on purpose, because filling them would mean
inventing an identifier that belongs to the target system: Location ID
(locations has id/name/country and no Cornerstone id), Position ID (our
S-0001 is not a Cornerstone position), Months of Service (Cornerstone
derives it) and Rehired Employee (the value for "yes" is unconfirmed,
and an unconfirmed value costs the whole row). Retention Rules and
Organisationsstufe stay empty because the load ignores them.

Header and row now match the test file byte for byte, except User ID
(no TEST- prefix outside a test load), Required Training Approvals and
Location ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:10:56 +02:00
478364e2d0 Add the Cornerstone export to Berichte
The 57 columns of the Cornerstone user import, in the given order and
spelling, offered as CSV and Excel under the Honestly report. Same
people and same filters as the full export.

Two things about the file itself, both of which would have failed
quietly. Cornerstone reads comma-separated, while toCsv writes the
semicolons and the BOM that German Excel wants -- the BOM would have
hidden inside the name of the first column, so "User ID" would have
matched nothing in the mapping. toCsv now takes the form as an argument
and keeps its old defaults; the Cornerstone form lives next to the
columns so a test can hold both. Quoting follows the delimiter now,
otherwise a comma in an address would split the row.

Dates go out day-first, matching the import setting. An ISO date is
read as a different, equally valid date and nobody notices.

Gender maps to Cornerstone's own values; anything unexpected becomes
"not specified" rather than empty, because an invalid value makes
Cornerstone reject the whole row, not just the field.

Status and Employment Status come from separate rules: somebody on
Karenz has a working account and is not working, and filling both from
one value gets one of the two wrong.

Four fields carry visible placeholders because the leading systems do
not supply their identifiers yet: Division ID, Doxis, Interflex,
LGVplus. Home Phone and Personal Email stay empty on purpose -- what
leaves the house is the business data.

Not verified against a running Cornerstone import, and not seen in a
browser; there is no database reachable here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 11:21:25 +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
1baf8c2867 Im Druck jeden untersten Bereich als Blatt, und die Zwischenebene zeichnen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 12m9s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m41s
Die Blaetter kamen aus `root.children`, also aus den unmittelbaren Kindern
der Gesellschaft. Das stimmte, solange die Bereiche dort hingen. Beim
Kunden liegt "CEO" dazwischen und ist selbst ein Bereich: die Auswahl bot
einen einzigen Eintrag an, und aus 784 Personen wurde die Uebersicht plus
ein Blatt fuer CEO.

Genommen wird jetzt jeder Bereich, unter dem kein weiterer liegt -- nach
dem Etikett und nicht nach der Stellung im Baum. Nur nach dem Etikett zu
gehen genuegte nicht: CEO kaeme als eigenes Blatt dazu und enthielte die
sechs anderen noch einmal.

Die Uebersicht haengte die gewaehlten Bereiche unmittelbar unter die
Gesellschaft und haette CEO damit stillschweigend uebersprungen. Sie
zeichnet jetzt den echten Weg von oben, beschnitten auf die Aeste mit
einem gewaehlten Bereich, und geht so tief, wie dieser Baum reicht. Das
Ab- und Anwaehlen wirkt weiterhin bis in die Uebersicht.

Bei der Gesellschaft entfaellt "Leitung unbesetzt": sie wird nicht
gefuehrt, sondern ist das Ganze. Ueberall sonst bleibt der Vermerk.
2026-09-23 10:19:53 +02:00
683e7cc2d7 Keep private email out of the Honestly export
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
The Email column exported employees.email, which is the person's
private address. That does not belong in a file sent to an outside
survey provider, least of all as the address invitations go to in the
employer's name.

The column now stays as a placeholder for the work email, which does
not exist in the schema yet and will be added later. It is empty until
then, but keeps its place so the column mapping set up in Honestly does
not have to change once the address arrives. The export no longer
reads employees.email at all, so it cannot end up in another column
by accident either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:10:44 +02:00
779d6116b2 Add the Honestly survey export to Berichte
A participant list for the employee survey on honestly.de, offered as
CSV and Excel right under the full data export. Columns: Personalnummer,
Email, Firstname, Last Name, Language, Location, OU, OU+1, ... , Role.
Language is always "de", Role always "Respondee".

The org columns run bottom-up: OU is the person's own unit, OU+1 the one
above, up to the top node. Their number follows the deepest chain in
the file, shorter chains are padded with empty cells, and there is
always at least one OU column so the file keeps its shape.

It exports exactly the people the full export would, with the same
filters. To guarantee that, the selection moved out of the full export's
route into lib/export-auswahl.ts and both routes use it; the full
export's columns are untouched.

Email is employees.email, which is the private address and optional --
there is no work address in the schema. Missing addresses stay empty
rather than getting a placeholder that would receive an invitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:10:43 +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
e727fac4b7 Der Streifen neben der Bildlaufleiste waren fuenfzig Schatten
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Anmerkung 5, jetzt mit Ursache statt mit Vermutung.

SlideOver bleibt geschlossen im DOM — absichtlich, sonst gaebe es keine
Schiebebewegung. Geschlossen steht das Panel per translate-x-full hinter dem
rechten Bildschirmrand, die eigene linke Kante genau auf dem Rand. Der
Schatten ist das Einzige, was von dort noch ins Bild reicht: 32 px
Weichzeichnung nach aussen, und aussen heisst hier nach links, in die Seite
hinein.

Bei einem Panel faellt das nicht auf. Die Protokollseite rendert aber eines
**je Zeile** — AuditDetail haengt an jeder. Bei fuenfzig Lagen zu 18 %
Deckkraft bleiben rechnerisch 0,01 % Durchsicht: ein fast schwarzer Streifen
neben der Bildlaufleiste, mit Stufen dort, wo sich die Weichzeichnungen der
einzelnen Lagen ueberlagern. Genau das war zu sehen, und genau deshalb nur
auf dieser Seite.

Die Tiefe gehoert zum geoeffneten Panel, nicht zum weggeschobenen. Sie erst
beim Oeffnen zu setzen kostet nichts.

Die Aenderung am Ueberlauf der Tabellenkarten aus dem vorigen Commit bleibt
— sie war eine Aufraeumung, die fuer sich steht, aber nicht die Ursache.
2026-09-16 22:36:37 +02:00
8c8777467c Die Tabellenkarten scrollen nur noch waagrecht
Zu Anmerkung 5 ("In Audit schaut die Scrollbar komisch aus").

`overflow-x-auto` allein genuegt nicht: nach CSS wird eine auf `visible`
stehende Ueberlaufachse auf `auto` hochgestuft, sobald die andere nicht
`visible` ist. Die Karte war damit ein Scrollbereich in beiden Richtungen
und konnte eine senkrechte Leiste zeigen, die nichts bewirkt — die Karte ist
so hoch wie ihr Inhalt, es gibt dort nichts zu scrollen.

Geklemmt wird durch `overflow-y-hidden` nichts, aus demselben Grund. Die
drei Stellen mit diesem Muster sind Protokoll, Mitarbeiterliste und
Importmappe; alle drei sind gleich behandelt.

Ob das *die* Ursache des gemeldeten Bildes ist, ist damit nicht bewiesen —
aus dem Bildschirmfoto allein laesst sich nicht ablesen, zu welchem Element
die zweite Leiste gehoert. Es ist der einzige Scrollbereich auf der Seite
und eine Aufraeumung, die fuer sich steht.
2026-09-16 22:27:22 +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
b9da3f3411 Planstellen klonen — ausser den leitenden
Anforderung 11 aus dem Workshop. Migration 20260915160000.

In der Fertigung sind Planstellen reihenweise gleich: zwoelf
"Maschinenbediener:in" in derselben Abteilung auf derselben Kostenstelle.
Von Hand angelegt sind das zwoelf Gelegenheiten, die Taetigkeit
unterschiedlich zu schreiben — und ab der zweiten Schreibweise steht sie
zweimal im Katalog und jede Auswertung nach Taetigkeit ist falsch.

Der Klon nimmt Einheit, Taetigkeit (denselben Katalogeintrag) und die zum
Stichtag geltende Kontierung. Nicht mit kommt die Besetzung: eine
Planstelle ist ein Platz, keine Person, der Klon ist frei.

Leitungsplanstellen sind ausgenommen, und zwar mit einer eigenen Meldung.
Je Einheit gibt es genau eine, und ein Unique-Index sichert das ab — ohne
die Pruefung waere ein Klonversuch entweder "duplicate key value violates
unique constraint" oder, mit stillschweigend fallengelassenem is_chief,
eine Planstelle, die anders ist als ihre Vorlage, ohne dass es jemand
angefordert hat. In der Liste fehlt der Knopf dort; das ist Bequemlichkeit,
die Regel steht in der Funktion.
2026-09-15 22:55:33 +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
b23ee17608 Logo.tsx wieder vollstaendig machen
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Zwei Handaenderungen hatten das Bauteil halb umgebaut: `aufRosa` war aus der
Signatur von MannerLogo verschwunden, wurde im Rumpf aber weiter benutzt
(erster Build: "Cannot find name"), und nach dem Nachbessern reichte
AppWortmarke die Eigenschaft weiter, die es nicht mehr gab (zweiter Build:
"Property 'aufRosa' does not exist"). Beide Male scheiterte der Typcheck im
Container, und damit blieb der Stand von d2d5e1d unausgeliefert.

Wiederhergestellt ist genau dieser Stand. `aufRosa` wird gebraucht: auf einer
rosa Flaeche darf das Logo kein eigenes Feld bekommen, sonst liegt wieder ein
Rechteck darauf — auf dem Panel der Anmeldeseite waere es das Raster, das
unter dem Feld endet.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:30:03 +02:00
0fbafa9b95 Update Logo component 2
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m35s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
2026-09-15 21:17:00 +02:00
76009d6db6 Update Logo component
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m27s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
2026-09-15 21:10:49 +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
8016d317be Das Logo ohne Feld auf Rosa, und kein Schwarz mehr auf der Marke
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m16s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m15s
Zwei Dinge, die beim Ansehen aufgefallen sind.

**Das Rechteck auf dem Panel.** Das Logo bringt sein rosa Feld selbst mit, und
auf der rosa Flaeche stand es als Rechteck in leicht anderem Ton darauf. Der
Ton war dabei derselbe — verschoben hat ihn die Lichtblende, die ueber der
Panelflaeche liegt und unter dem Logo endete.

Das Manual kennt fuer den Schriftzug zwei Faelle, und sie brauchen
verschiedene Dateien: auf Weiss gehoert er nach §4.1 in ein rechteckiges rosa
Feld, in einem grossflaechigen rosa Umfeld dagegen wird er nach §3.1 nur mit
Freiraum integriert — die Flaeche ist ja schon da. Es gibt deshalb jetzt
manner-logo-auf-rosa.svg ohne eigenes Feld, und die rosa Flaechen benutzen es.
Damit bleibt nichts mehr uebrig, wo die Blende enden koennte.

Die Blende selbst ist weg. Sie war Weiss auf Rosa und hellte die Markenfarbe
so weit auf, dass sie kaum noch die Markenfarbe war; geblieben ist ein feines
Raster und eine leichte Tiefe zur unteren Ecke, beides aus ink.

**Schwarz auf der Marke.** "Uebersicht", "Berichte" und der Rest der Kopfzeile
standen in ink. Das traegt zwar (7.47:1), sieht aber aus wie Text, der aus
Versehen auf der Marke gelandet ist. Alles Geschriebene auf Rosa steht jetzt
in brand-700: 6.60:1, und es ist dieselbe Paarung, aus der der Schriftzug
selbst besteht — blaue Lettern auf rosa Feld.

Dabei noch eine Schwaeche gefunden: das Abzeichen an der Glocke kam als
danger-solid auf dem rosa Band nur auf 2.78:1 und blieb unter den 3:1, die
WCAG 1.4.11 fuer ein bedeutungstragendes Element verlangt. Mit danger-text
sind es 3.26:1, und die Ziffer darin steht mit 7.12:1 besser als vorher.

**Und ausgemistet.** Die Logodatei wog 74 kB, weil die Vorlage aus Seite 3 des
Manuals gezogen wurde und die ganze Seite mitgenommen hat: 219 Elemente, die
den Fliesstext jener Seite zeichnen ("Der Manner Schriftzug ... darf nicht
veraendert werden"), unsichtbar rosa auf Rosa oder ausserhalb des Beschnitts.
Das eigentliche Logo sind vier Pfade. Beide Fassungen wiegen jetzt 18,7 kB.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:44:01 +02:00
3987e8f54f Rosa fuehrt, Blau bedient
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m1s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m1s
Die Flaechenverteilung war verkehrt herum. Blau lag auf dem Groessten, was die
Anmeldeseite zu vergeben hat, und Rosa nur in Andeutungen daneben — bei einer
Marke, deren Schriftzug auf Rosa steht und deren Manual Rosa zuerst nennt.

Sichtbar wurde es am Logo. Es bringt sein rosa Feld selbst mit, weil §1.2 den
Schriftzug nur zur Gaenze auf Rosa zulaesst. Auf einer blauen Flaeche steht
dieses Feld als ausgeschnittenes Rechteck darauf. Auf Weiss ist es dagegen
richtig — §4.1 verlangt fuer weissen Untergrund genau das, ein rechteckiges
rosa Feld. Falsch war also nicht das Logo, sondern die blaue Flaeche darunter.

Rosa bekommen jetzt: das Panel der Anmeldeseite und das Band ueber der
Anwendung aus Kopfzeile und Logokopf der Seitenleiste. Beide zusammen sind ein
durchgehender Streifen, in dem das Logo aufgeht statt darauf zu liegen — was
§3.1 mit "in einem grossflaechigen rosa Umfeld eingebettet" meint.

Blau bleibt, was es ist: Knopf, Link, Fokusring, ausgewaehlter Eintrag. Das ist
dasselbe Verhaeltnis, aus dem der Schriftzug besteht — blaue Lettern auf rosa
Feld — und es ist das einzige, das traegt. Weiss auf Rosa sind 2.18:1.

Auf den rosa Flaechen steht deshalb nichts Weisses und nichts Gedaempftes mehr.
ink kommt auf 7.47:1, brand-700 auf 6.60:1; ink-muted lag bei 2.33:1 und ist
aus Kopfzeile, Glocke und Logokopf verschwunden. Gestuft wird ueber Groesse und
Gewicht statt ueber Transparenz — eine aufgehellte Schrift ist genau das, was
auf dieser Flaeche durchfaellt.

Das Raster auf dem Panel ist von Weiss auf ink gewechselt: auf Rosa war es
nicht mehr zu sehen.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:47:26 +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
6903013548 Link statt a-Element, und check deckt ab, was der CI prueft
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m12s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
2026-09-10 10:01:08 +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