cf0ea51f47442a043cc56e7054cbe9bab19e9035
61 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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.
|
|||
| 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.
|
|||
| 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. |
|||
| 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.
|
|||
| 7a33e493b5 |
Die Historie bekommt ihre eigenen Farben
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> |
|||
| d2d5e1dabb |
Drei Befunde aus dem Test: Logo, Geplant-Filter, Anstehend-Farben
**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> |
|||
| 8016d317be |
Das Logo ohne Feld auf Rosa, und kein Schwarz mehr auf der Marke
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>
|
|||
| 3987e8f54f |
Rosa fuehrt, Blau bedient
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> |
|||
| f7a5c48615 |
Die Oberflaeche auf das Manner-CD umstellen
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> |
|||
| f17d299045 |
Lehrling, and a field that stops being named after its values
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> |
|||
| eeaf210e78 |
Let the same choice open notes and drafts
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> |
|||
| eb25369d1d |
Let the truncated Anstehend list open
"… 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> |
|||
| 99b1df9735 |
Choose whose notes reach your bell
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> |
|||
| c6cff9656e | Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell | |||
| 405d708bc4 |
Sort from the column headers, all seven of them
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> |
|||
| b87c8ad64c |
Remove Supabase
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> |
|||
| 5c310c3a58 |
Let the employee list be sorted A-Z or Z-A
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> |
|||
| e958bb5c6b |
Stop pretending Vercel is an option
It was never used. The repository lives on a self-hosted Gitea, which Vercel's git integration cannot connect to at all — so the documented route amounted to "mirror to GitHub first", and nobody did. vercel.json is gone, and with it the branch in next.config.ts that switched off `output: "standalone"` when the VERCEL variable was present. That branch was the only functional trace; everything else was documentation and comments describing a second deployment path that did not exist. DEPLOYMENT.md loses its "two supported ways" framing and the whole Vercel section — about fifty lines. Several statements next to it were stale for a different reason and are corrected in the same pass: the outbound-firewall table still listed Supabase's pooler (the database is a container now, nothing leaves the server), the prerequisites still demanded an existing Supabase project, and the .env table still asked for a pooler connection string instead of the two new passwords. The nightly job is described as what it is — a container in docker-compose.yml — rather than as a replacement for Vercel Cron. Migrations keep their references: two comments from July mention Vercel Cron, and they describe what was true when they were written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 19e3170b00 |
Print a checklist without printing the application around it
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> |
|||
| f85731dde5 |
Give exits their own checklist, next to the entry one
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> |
|||
| 82d07f0d95 |
Put the onboarding checklist where the file is
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>
|
|||
| 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> |
|||
| 10f8f1f2d5 |
Keep a note on the overview until somebody ticks it off
A note with a follow-up date is a task, and the overview is where tasks are looked for. Until now it lived only in the employee file, which is the one place you go when you already know who you are looking for. Follow-ups behave differently from everything else on that card, and the difference is the point: an entry on Monday is over on Tuesday, an unfinished task is not. So there is no lower bound on the date — what was due and never ticked off stays, marked overdue in red, sorted to the top because it is sorted by date. A task that drops out of the list by itself is a forgotten task. Only "Erledigt" removes it. A note without a follow-up date never appears: it is a record, not a task. Checked against the live database — an overdue one and an upcoming one appear, one without a date and one beyond the chosen period do not, and ticking the overdue one off removes exactly it. The probe notes were deleted again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 91b2b3406b |
Stop waiting on the network eleven times per page
The app got slower as pages grew, and the reason was not the queries. It was their number. A transaction is pinned to one connection, and a connection runs queries one after another. Every Promise.all in a withUser block looked like concurrency and was a queue. Measured against the real database: the round trip is ~36 ms, ten trivial `select 1` over one connection take 343 ms, over ten connections 39 ms. Nothing here is slow — the whole dashboard payload is under 200 kB, and every table is around a thousand rows. More connections is the wrong answer: the RLS session context is per transaction, so parallel reads mean parallel transactions, and those multiply the connections the database will grant. Fewer round trips instead. Postgres will return each sub-select as its own JSON column of one result. Per page view, counting the transaction frame: shell (paid by every page) 10 → 4 overview 14 → 5 employee file 14 → 7 employee list 8 → 6 The overview plus its shell went from 24 round trips to 9 — about 860 ms of pure waiting down to about 320 ms. The one trap is documented where it bites: inside json_agg, Postgres formats values itself and the driver's parsers (lib/db/pool.ts) never see them. Dates, numerics and uuids come out identical; timestamptz does not — "+00:00" where the driver gives "…Z". Timestamps are compared as strings in lib/history.ts to decide what happened later, and those two forms sort against each other wrongly. Every timestamptz in a bundled query therefore goes through zeitstempel(), which was checked character-for-character against the driver. Four loaders moved out of their pages into lib/ so the number of round trips can be measured without building a React tree, and so the new path could be held against the old one field by field: same rows, same order, same strings, for the overview and for four employee files chosen to differ (with history, a chief, a planned entry, one with dependents). withUser now counts the queries in each transaction and says so in development past a threshold. Without that, this grows back: each new tile brings its own query, and nobody notices until everybody does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 6957b95a97 |
Let the overview say what "upcoming" means
Sixty days and all three kinds was a guess, and it was the only one on offer. Payroll cares about next month; the person filling a vacancy cares about entries and nothing else. The card now takes a period and a set of kinds. The choice lives in the address rather than in the browser, because it has to: the page is built on the server, and ninety days pulls in rows that were never loaded at sixty. Filtering client-side would silently cap the answer at whatever the first query happened to fetch. It also means a filtered overview can be sent to someone and opened again the same way. Deselecting every kind returns to all of them. An empty card is not an answer to a question nobody asked, and the way back would otherwise be one click further than the way in. The default period and the full set are absent from the URL instead of written into it, so a shared link carries only what was actually chosen. Anything the address cannot be trusted to hold is rejected: an unknown period falls back to sixty rather than reaching the query, which would otherwise be an invitation to ask for ten years of rows through a link. Eight rows still, with a count of what did not fit underneath — this is an overview, and the employee list is where lists belong. Not verified in a browser: the built-in preview has no company sign-in, so the page redirects to the login before it renders. Types, lint and 386 tests pass, and the filter's behaviour is covered directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 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> |
|||
| 9308096754 |
Search names first, and only fall back to job titles
"Winkler M" returned four people, two of whom are not called M: Karin Winkler is a Montagemitarbeiterin and Katharina Winkler a Maschinenbedienerin. The job title was searched with the same weight as the name, so a single letter matched the start of a job word just as readily as the start of a first name. Searching job titles is worth keeping — "dreher" finding the CNC-Dreher is useful. So the search is now tiered: names alone first, and the job title joins in only when the names return nothing at all. A minimum word length would have been the simpler rule, but any threshold is a guess; this one is decided by the data in front of it. Checked against the live data: "winkler m" gives Martin and Magdalena, "winkler h" Hannah, "dreher" and "montage" still find their trades, and "winkler montage" finds Karin Winkler — no name matches both words, so the fallback does what was meant. When the fallback runs, the result line says so. Without that, a list of people whose names look nothing like the query reads as though the search invented them. Costs one small count query, and only when text was typed. Not verified with next build: a dev server from an earlier session is holding .next, and the user is testing in it. tsc, eslint and 297 tests are green, and the search itself was run against the database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 33d053ce75 |
Match search words at the start of a word, not anywhere inside one
Searching "Winkler H" returned all seven Winklers instead of the one Hannah. Each word was matched as a substring, so "H" hit T-h-omas, Kat-h-arina and CNC-Dre-h-er:in — every row. The shorter the input, the more useless the result, and an initial is the shortest input anyone would type. A word now has to match at the start of a word: either the haystack begins with it, or a space does. The haystack is first name, last name and job title joined, with hyphens, slashes, colons and dots flattened to spaces, so "dreher" still finds CNC-Dreher:in and "cnc" still finds both the Dreher and the Fräser. Checked against the live data before and after: "winkler h" now returns Hannah Winkler alone, "h winkler" the same in either order, "winkler kat" the two Katharinas, "dreher" the twelve CNC-Dreher. The trigram index on the concatenated name no longer applies, which is the price. At under nine hundred rows the scan is a few milliseconds; an index on the same expression brings it back when that stops being true. LIKE's own wildcards are escaped now — typing "100%" searched for everything before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 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> |
|||
| 3151f32404 |
Print the org chart as an org chart, and ask first what to print
The chart on screen is an infinite canvas: you zoom in, drag around, and look at one corner at a time. Paper has none of that. Printing the canvas means scaling 850 people onto one sheet, which yields boxes two millimetres wide — technically the whole company, practically nothing. So the print view is rebuilt rather than shrunk, and it does two things the canvas cannot. It asks before it prints. Depth (bereiche, abteilungen, teams, or teams with every name) and which divisions, each one selectable. Whoever needs Produktion for a meeting gets two sheets instead of forty, and the page count is on the button before anything reaches the printer. And it draws the hierarchy as a hierarchy: boxes joined by connecting lines, not a column of cards. Superior and subordinate are the entire point of an org chart; a tidy list of the same units simply does not say it. The lines come from borders on pseudo-elements, so the PDF keeps them as vectors and they stay sharp when someone zooms in. Header shading gets weaker with each level down, which survives the black-and- white printer that most of these end up on. Overview sheet first, then one sheet per selected division, each carrying its own heading and headcount so page seven is still readable on its own. A4 or A3, landscape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 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> |
|||
| 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>
|
|||
| 53d5d41784 |
Search a name by any of its words, in any order
Reported from use: typing "Winkler micha" suggests there is no Winkler at all, when there are fourteen. The search compared the whole term against each field separately, so a two-word entry matched nothing — neither the first name nor the last name contains "Michael Winkler" as a string. Both orders failed; the report noticed one of them. The term is now split on whitespace and every word must match somewhere. That is more than was asked — the request was to search surname first — but reversing the expected order only mirrors the problem: you would still have to remember which way round it goes. "Winkler kath" and "kath Winkler" both find the two Katharina Winklers now, and "Winkler Produktmanager" finds the two in that job. Matching runs against the concatenated name rather than the separate columns, because that is exactly what idx_employees_name_trgm indexes. The old query could not use it. A second defect in the same block: the personnel-number branch tested /^d+$/ — a missing backslash, so it matched strings of the letter d and never a number. Searching "3488" fell through to the name search and found nothing. It now reaches Peter Bauer. Verified against the live database, before and after, for both orders and for a plain surname, which still returns all fourteen. One thing the report's screenshot cannot show any more: there is no Michael Winkler in the current data. The database was reseeded, and those names are from the previous set — worth knowing before checking with that exact name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 27e0e8a8ce |
Ask for someone's organisation on a day they actually have one
An employee starting 01.09. showed "Keine Führungskraft (Geschäftsführung)" although their team has one — Josef Bauer, on chief position 60000752. The reporting line was requested as of today, and today that person holds no assignment, so om_reporting_lines() returned no row at all. The database function was right; the caller asked the wrong question. What made it look like a data problem rather than a date problem: the header did show the unit and the position, because pickPlacements() falls back to the next best assignment when none is current. Two notions of where someone sits — one forgiving, one strict — sitting next to each other on the same page. orgAsOf() pulls the date into the employment: the first day for someone not yet started, the last for someone who has left, today otherwise. Exit dates are exclusive throughout the model, so the last working day is the day before. Anyone already gone had the same defect for the same reason, which is why the rule covers both ends rather than special-casing the case that was reported. Verified against the live database: as of today no row, as of 2026-09-01 the manager is Josef Bauer. Six unit tests over the boundaries, checked by mutation — remove the future-entry branch and one fails. Open positions still resolve as of today: they belong to the organisation, not to the person whose file is open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| d8a1fdf43b |
Reinstate the Vercel build settings
Reverts |
|||
| 61ccce5456 |
Revert "Make the build fit Vercel without breaking the container"
This reverts commit
|
|||
| ecbda3f3a5 |
Make the build fit Vercel without breaking the container
Two settings were wrong for a platform build. output: "standalone" tells Next.js to emit a self-contained server, which is what the Dockerfile copies in — and what Vercel neither needs nor expects, since it builds and packages the app itself. It is now conditional on the VERCEL variable, which every build there sets, so each path gets what it wants. Verified both ways: with VERCEL=1 no standalone directory appears, without it one does. /api/import declared maxDuration = 120. The free tier caps at 60 and refuses anything higher, so the deployment would have failed on a value chosen for a self-hosted server. Lowered, with the reason and the Pro ceiling written next to it. DEPLOYMENT.md now covers both paths, and says plainly that the repository cannot be connected: git.elycon.solutions is self-hosted, and Vercel's git integration only speaks GitHub, GitLab and Bitbucket. Deploying from the workstation with the CLI works with any repository and is the shorter road; mirroring to GitHub is written down as the alternative, with its cost — two remotes to keep in step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 1271cef879 |
Record what a change was, not only which field it touched
The audit log said "Adresse, wirksam ab 30.07.2026". That names the field
and hides the answer: what did it say before? For a personnel record that is
the question the log exists to answer.
Both values are in hand at the moment of the change — v_old holds the row as
it was, the payload holds what is being written. change_employee_data
already compared them to decide whether to mention the field at all, then
dropped them. It now keeps them in audit_log.changes as
[{feld, vorher, nachher}], and derives the old one-line text from the same
array so existing views are unaffected.
Clicking a row opens the detail. Fields with no previous value read "leer"
rather than showing an empty cell, because "was not set" is itself a
statement.
Two honest limits, both stated in the panel rather than left to look like a
bug:
- Existing entries cannot be enriched. The values were never captured;
there is nothing to recover.
- Hire, exit and import record no individual fields, so they show none.
The rewritten function also drops auth.uid() for app_current_user_id(),
which works on either system — one of the last few call sites before #23.
Caught while writing this: my scripted edit of types.ts silently did nothing
and my own check reported success, because the pattern matched
pending_org_changes. Redone with the editor. That is the second time a
regex-driven edit has lied about its result in this project.
Not verified end to end: the migration needs privileges I no longer hold
after the database password was rotated. Until it is applied the audit page
will not load, since it selects a column that does not exist yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| 3926f1bb80 |
Load a whole organisation from a file, or none of it
Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.
Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.
Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.
Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:
- weekly_hours is bound to employment type by a CHECK constraint: full time
is exactly 38.5. The import reached the insert and was rolled back. Now
it is a finding with a row number.
- Titles are restricted to a fixed list by another CHECK. Same treatment.
- setval() needs UPDATE on the sequence, which `usage, select` does not
grant. Migration 20260803120000 adds it; until it is applied, an import
containing people will fail at the last step and take itself back.
I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.
Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| 2ba9b37aa7 |
Hand the front door to Entra, and keep the keys out of the build
Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.
lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.
That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.
The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.
Two things only came out by running it:
- `export const proxy = auth(…)` is not a function declaration, so
Next.js never found it and every request 404'd. `next build` reported
success and listed the proxy. In the function config form auth() also
returns the handler as a promise, so it needs an await. The proxy test
now mocks it as a promise for that reason — a friendlier mock would
let the same bug back in.
- A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
/common/, and the redirect really did go there. That would let any
Microsoft account sign in, including a private one, and it would never
look broken. It now refuses to start in production.
Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.
Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| b3a0af2b8f |
Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.
Was dabei ersatzlos verschwindet:
- fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
Am direkten Zugang ist eine Abfrage eine Abfrage.
- sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
abgesichert, sondern weg.
- lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.
Nebenbei besser geworden, weil der direkte Zugang es erlaubt:
- Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
Lesestand statt in fünf unabhängigen Anfragen.
- Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
eingebetteten Ressource mit !inner — eine Person mit mehreren
Zuordnungen über die Zeit erschien dort mehrfach.
- Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
- Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
- Namen an Ereigniszeilen kommen aus einem Join statt aus einem
Nachschlag, der ausserhalb der Transaktion lag.
Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.
Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.
Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
|
|||
| 2cce101c4b |
Sign in with Entra ID, and give the login screen something to look at
Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die
Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb
ist der Eingriff klein: auth.uid() liefert weiterhin eine UUID, profiles.id
trägt weiterhin role und is_active, und damit bleiben is_hr_user() und alle
58 RLS-Policies unverändert gültig. Die Sicherheitsgrenze wandert nicht in
den Anwendungscode.
Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem
Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten
Zugriff, Sperrung beim Austritt.
Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung
tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach
/login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt
nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route,
sondern profiles.role/is_active und darunter die Policies.
Zwei Werkzeuge für die Umstellung:
- relink-profile.ts hängt eine bestehende profiles-Zeile auf die
Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben
Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das
zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man
sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst
stünde in der Historie eine Kennung ohne Konto dahinter.
- entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat.
Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der
Anspruch heisst und aussieht, und das unterscheidet sich je nach
Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut,
sonst wäre er geraten.
Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities.
identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete
Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich
jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md.
Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine
Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die
Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht
mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser
Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit
grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man
wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung,
die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten.
Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px,
Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px.
Typecheck, Lint, Build und 182 Tests sind grün.
|
|||
| 27669e0359 |
Put the whole application on the OM model, and delete what it replaced
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
|
|||
| a0973cff66 |
Show the absence type in the employee list again
The three pending migrations have been applied, so absence_type exists and
the workaround that kept it out of this select can go. The status chip in
the list shows the specific kind again ("Bildungskarenz" rather than the
generic "Langzeitabwesenheit"), matching the detail page.
Verified against the database rather than by typecheck: employee_assignments
holds 809 rows for 809 employees with exactly one open interval each,
is_valid_svnr() agrees with the TypeScript implementation on all nine
documented cases, and the list, org-chart and detail queries all return rows.
|
|||
| a9bf439624 |
Collapse sequential query waves, and stop selecting an unshipped column
Against the hosted database a round trip costs about as much as the queries themselves (~90ms), so page time was dominated by how many waves ran in sequence rather than by the SQL. Measured with the median of five runs: - Employee list 244ms -> 101ms. It awaited loadOrgMaps and only then the page of employees; the lookup tables are needed to label rows, not to build the query, so both now go out together. - Employee detail 120ms -> 62ms. Nine of the ten queries key off the id already in the URL and had no reason to wait for the employee row. The manager comes back as an embedded resource on that row instead of a follow-up query, which is what makes it one wave rather than two — an intermediate version that merely reordered the waves measured *slower*, and the embed is the part that actually helps. - Reports 197ms -> 180ms. Three waves became two. Modest, and worth saying so: the snapshot query itself dominates that page, not the wave count. Also fixes a blank employee list I caused. `absence_type` was added to the list's explicit column list ahead of its migration, and PostgREST rejects the *entire* query for one unknown column — so `data` came back null and the page rendered zero of 809 employees rather than just dropping a chip label. The column is out of that select until 20260726120000_absence_type.sql is applied; the detail page selects "*" and shows the kind once it exists. Verified against the real database rather than by typecheck alone, which is what would have caught it in the first place. |
|||
| 8282d7f581 |
Rename Karenz to Langzeitabwesenheit and record its type
Karenz was doing duty as the name for every kind of extended absence, but the cases behave differently in payroll and reporting — Wochenhilfe, a Präsenzdienst, a long sick leave and a sabbatical are not the same thing. The concept is now called Langzeitabwesenheit and carries which kind it is. - employees.absence_type, constrained to the thirteen kinds. start_karenz stores it on both paths (written straight away, or parked in the pending_org_changes payload when the absence starts later); record_karenz_return and the karenz_return branch of apply_due_pending_changes clear it, so a returned employee does not keep looking like they are still away. It also reaches employee_history, the audit log and the employee export. - The status enum value stays 'Karenz'. Postgres can rename an enum value in place, but every stored function body that spells it would then reference a value that no longer exists — a dozen functions across fifteen migrations, rewritten for a label. The mapping lives in lib/absence.ts instead, which is the single place the UI reads the display name from. - Where a kind is recorded the chip shows it — "Bildungskarenz" says more than "Langzeitabwesenheit". Absences predating the field have none and fall back to the generic name rather than to a guess, and a value outside the list is dropped rather than echoed into the UI. - The export prints the display name, not the raw enum: a payroll hand-off reading "Karenz" for what the app calls Langzeitabwesenheit only causes questions. Audit filter options keep their stored values and change only their labels. - The seed spreads the twelve absences across the kinds; all of them being Karenz would leave any breakdown by kind invisible. |
|||
| 37bb107cd4 |
Visual pass, clickable KPI tiles, and one consistent definition of status
Visual - `--radius: 8px` in @theme collapsed Tailwind v4's whole radius scale onto a single value: `rounded` and `rounded-lg` both measured 8px, so a chip, an input and a card could not be told apart. Named steps restore the gradation (6 / 8 / 12px, measured in the browser). - Cards were a 1px border and nothing else. Added warm, brand-tinted elevation tokens — a neutral black shadow over the pink surface reads as dirt — in three steps for cards, dropdowns and overlays, collected behind components/ui/Card.tsx so the 26 hand-copied card class chains have one definition. - KPI tiles lead with the number and carry a tone accent; tables got denser rows, subtle row rules (the full border strength made 800 rows read as a grid), tabular figures in numeric columns and a brand-tinted hover. KPI tiles now link to the view that shows what they count. Making those links honest surfaced two reasons the numbers did not agree with their destinations: - The dashboard read `employees.status`, while every report derives status from entry/exit/karenz dates. A hire whose start date had passed before the cron ran was counted differently on the two pages. The dashboard now uses the same derivation — and one query instead of five. - Eintritte/Austritte counted `entry_date`/`exit_date` while the linked report counts `employee_history`; rehire_employee sets entry_date but logs the event as 'Wiedereintritt', so rehires were missing from the target. Both now count history events. - The employee list filtered on the status column, so it disagreed too. It now filters on derived status in SQL (lib/employee-status-filter.ts). That restates deriveStatusAsOf a second time, in a second language, so an integration test runs both over the full roster and requires identical id sets — drift here is otherwise invisible. Status semantics, per the domain correction: "aktiv" means status Aktiv alone. Karenz is employed but not active, and has its own tile. The active headcount, FTE (Karenz contributes no capacity) and the division bars all follow that; the bars are labelled "Aktive nach Bereich" rather than "Headcount" to say so. The employee filter still offers the combination, named after the two statuses it selects instead of calling the pair active. DEFAULT_STATUSES in lib/reports.ts is deliberately left at Aktiv + Karenz: it governs what the Berichte page shows without an explicit status filter, and therefore what already-saved reports and exports mean. |