Compare commits

...

57 Commits

Author SHA1 Message Date
7b7562799d Die Rueckfragen auf den Stand nach dem Gespraech bringen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m5s
Vier der sieben Fragen sind am 17.09.2026 geklaert. Sie stehen jetzt mit dem
Ergebnis im Dokument, damit nachvollziehbar bleibt, warum etwas so ist, wie
es ist — nicht geloescht, denn die Begruendung ist die Antwort.

Offen bleiben: die fehlenden Punkte 6 und 7 im Anforderungsdokument, was
"Niederlassungen auch in Zuordnung" bedeutet (unsere Lesart wurde
gestrichen, eine andere nicht genannt), und die Liste, wer tatsaechlich
Praktikum, geringfuegige Beschaeftigung oder Altersteilzeit hat.

Neu offen: ob "freiwillig oder unfreiwillig" fuer gewoehnliche Austritte
Pflicht werden soll — was freiwillig ist, bleibt oft leer, und dann traegt
die Fluktuationsauswertung Luecken. Und dass die Uebernahme rueckwirkend
nicht rekonstruierbar ist: in den Daten steht nur der heutige Stand der
Besetzungsart, nicht, wann er sich geaendert hat.
2026-09-17 12:18:12 +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
bf7f59a959 Ein Raster fuer alle acht Kacheln, nicht drei nebeneinandergestellte
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m24s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m8s
Gleich breit und gleich weit auseinander werden Kacheln erst, wenn sie
demselben Raster angehoeren. Die beiden Anlaeufe davor stellten drei eigene
Raster nebeneinander: zuerst mit gleichem Anteil je Gruppe (fuenf Kacheln
gequetscht, eine mit demselben Platz), dann mit einem Anteil nach
Kachelzahl. Da stimmten die Breiten fast — aber eben nur fast: eine Gruppe
mit fuenf Kacheln hat vier Abstaende in sich, eine mit einer keinen, und
dieser Unterschied verteilt sich auf die Breiten.

Jetzt acht gleiche Spalten und ein einziger Abstandswert. Die Ueberschriften
sitzen in Zeile 1 und ueberspannen die Spalten ihrer Gruppe (5, 2, 1), die
Kacheln in Zeile 2. Beide Zeilen entstehen aus derselben DOM-Reihenfolge —
Ueberschrift, ihre Kacheln, naechste Ueberschrift —, die fuer Vorlesegeraete
die richtige ist; die ausdrueckliche Zeilenangabe sortiert sie fuers Auge.

Die Spannweite steht ausgeschrieben in einer Tabelle und nicht als
span var(--kacheln): Tailwind erzeugt nur Klassen, die als Zeichenkette im
Quelltext stehen, und eine berechnete Spannweite waere zur Bauzeit nicht zu
sehen.

Unterhalb der grossen Breite keine Zeilenangabe: dann fliesst alles der
Reihe nach, Ueberschrift ueber die volle Breite, ihre Kacheln zu zweit
darunter.
2026-09-16 22:54:07 +02:00
c46f18c37a Die acht Kacheln stehen wieder in einer Reihe
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Der erste Anlauf gab jeder Gruppe denselben Anteil der Breite (flex-1).
Damit quetschten sich fuenf Kacheln in ein Drittel und brachen um, waehrend
die einzelne rechts dasselbe Drittel fuer sich hatte — gemeint war eine
Reihe, wie im Entwurf.

Die Breite einer Gruppe folgt jetzt der Zahl ihrer Kacheln. Die Zahl geht
als CSS-Variable in beide Regeln statt als ausgeschriebene Klasse je Gruppe:
kaeme eine neunte Kachel dazu, stimmte die Aufteilung von selbst, waehrend
ein von Hand gesetztes xl:flex-[5] jemand nachziehen muesste — und es fiele
nicht auf, wenn er es vergisst.

Unterhalb der grossen Breite weiterhin untereinander, zwei Kacheln je Reihe:
acht nebeneinander waeren auf einem Laptop unlesbar schmal.

Die Zahl wird in der Reihe kleiner gesetzt. Je Kachel bleiben dort gut
110 px, und "748.4" braucht in 30 px Schriftgrad mehr, als nach dem
Innenabstand uebrig ist — die Kachel hat overflow-hidden, die Zahl waere
also nicht zu breit, sondern abgeschnitten.
2026-09-16 22:47: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
a0811d75f4 Das Logo war nie kaputt — der Proxy hat es umgeleitet
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
`curl -sI` auf /brand/manner-logo.png antwortete mit **307**. Die Datei lag
die ganze Zeit da, ausgeliefert wurde sie nie: der Abgleich in proxy.ts
nimmt alles ausser _next/static, _next/image und favicon.ico, und public/
stand nicht darin. Eine Anfrage nach dem Logo lief also in den Gate, fand
keine Sitzung und bekam eine Umleitung nach /login. Auf ein <img> antwortet
der Server damit mit HTML, und der Browser zeigt den Ersatztext.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:39:58 +02:00
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
e1b69fb022 Festes Enddatum des Passwortpfads zuruecknehmen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m38s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m7s
2026-09-08 17:25:32 +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
cbde8cf3a8 Passwort-Anmeldung neben Entra, mit Enddatum im Schema
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 10m52s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
2026-09-08 16:32:58 +02:00
206a7c6eba Zeilenschutz auf vier Tabellen nachziehen, und den Zustand pruefen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Beim Neuaufbau der Datenbank aus den Migrationen am 26.08. blieben
cost_centers, position_cost_centers, onboarding_tasks und
offboarding_tasks ohne Row Level Security: sie verlassen sich auf
den Ereignis-Trigger ensure_rls, den eine spaetere Migration erst
anlegt. Ein Ereignis-Trigger wirkt nur nach vorne.

Die Policies auf diesen Tabellen existieren und wurden nie
ausgewertet — PostgreSQL befragt sie nur bei eingeschaltetem RLS.
In jeder Aufstellung der Policies sah es richtig aus.

Der CI-Job prueft ab jetzt den Zustand nach dem Lauf, nicht nur
dass die Dateien durchlaufen. Genau in diesem Zwischenraum ist der
Fehler durchgekommen.
2026-09-08 15:36:48 +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
029b63009f Den Postgres-Dienst beim Namen nennen, nicht ueber 127.0.0.1
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 10m58s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m5s
Bei GitHub laeuft der Job auf dem Host und erreicht einen Dienst
ueber den durchgereichten Port. Der Gitea-Runner setzt den Job
selbst in einen Container: dort ist 127.0.0.1 der Container selbst,
und dort horcht kein Postgres — 'Connection refused'.

Beide Container haengen im selben Netz, also gilt derselbe Weg wie
in docker-compose.yml: der Dienst wird ueber seinen Namen erreicht.
2026-09-07 10:42:02 +00:00
a2f2f7075e Ohne sudo — im Runner laeuft alles als root
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 10m54s
CI / Migrationen auf leerer Datenbank (push) Failing after 5m24s
Das Abbild des Gitea-Runners ist schlanker als das von GitHub und
bringt kein sudo mit. Es braucht auch keins: der Schritt laeuft
ohnehin als root.
2026-09-07 10:23:20 +00:00
17c7badd16 psql im Runner bereitstellen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 10m46s
CI / Migrationen auf leerer Datenbank (push) Failing after 5m18s
Der Job 'Migrationen auf leerer Datenbank' brach beim ersten Schritt
ab: 'psql: command not found'. Das Abbild ubuntu-latest bringt keinen
Postgres-Client mit, die Vorbereitung der Datenbank ruft ihn aber —
deshalb lief der Job nie bis zu dem, wofuer es ihn gibt.

Genau diese Pruefung haette die fehlenden Rechte der Anwendungsrolle
gefunden, bevor sie beim Umzug auftraten: auf einer leeren Datenbank
scheiterte die Anwendung mit 'permission denied for table profiles'.
2026-09-07 10:03:42 +00:00
d07dc80780 Datenbank-Abzuege wieder vom Repository fernhalten
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m4s
CI / Migrationen auf leerer Datenbank (push) Failing after 5m14s
Beim Aufraeumen fielen zwei Regeln mit heraus, die Abzuege mit
Personendaten ausschlossen. Die Dateien liegen weiterhin auf dem
Server, ein 'git add .' haette sie mitgenommen — 857 Personalakten
mit SV-Nummern, Adressen und Angehoerigen.

Jetzt nach Ort statt nach Namen: /*.sql trifft die Abzuege im
Wurzelverzeichnis, laesst db/migrations aber unberuehrt (fuehrender
Schraegstrich). Damit greift die Regel auch fuer kuenftige Namen.
2026-09-07 09:41:41 +00:00
4ac516daa1 Read the whole ALTER TABLE, not just its first clause
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m6s
CI / Migrationen auf leerer Datenbank (push) Failing after 5m24s
The schema/type drift check has been red in CI. It reported seven
discrepancies, and all seven were the checker's own fault.

The migrations put several clauses in one statement:

  alter table employees
    drop column if exists division_id,
    drop column if exists team_id,
    drop column if exists manager_id,
    drop column if exists org_level,
    drop column if exists is_lead;

The old pattern matched `alter table (\w+)\s+drop column (\w+)` as a single
regex, which finds exactly the first clause. So division_id was dropped from
the model and the other four stayed in it — the checker insisted four columns
existed that the OM cutover removed a year ago. The same cut the other way for
`add column`: kuendigungsschutz_bis, teilzeit_bis and aufenthaltstitel_bis are
each the second clause of their statement, so the checker never saw them and
called them typed-but-absent.

Now the statement is collected up to its terminating semicolon — with paren
depth tracked, so a semicolon inside a check constraint does not end it early
— and every clause inside is applied.

Verified by mutation, not by the green result alone: putting an invented
column into types.ts is caught, and reverting the parser to read only the
first clause brings back exactly those seven messages. That is the diagnosis
confirmed, not merely a passing run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:53:57 +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
af947f094d Rechte der Anwendungsrolle als Migration, Traefik-Konfiguration ins Repo
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m2s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m22s
Der Umzug in einen eigenen Container hat zwei Luecken aufgedeckt:

1. Die Rechte von alpenwerk_app standen in keiner Migration — sie waren
   auf Supabase von Hand im Dashboard vergeben worden. Auf einer leeren
   Datenbank scheiterte die Anwendung deshalb mit 'permission denied for
   table profiles', bevor die Anmeldeseite erschien.

2. docker-compose.traefik.yml lag nur auf dem Server. Ein frischer Clone
   haette die Anwendung ohne Routing hochgefahren. Zusaetzlich haengt app
   jetzt im Netz 'default', sonst findet es den db-Container nicht.
2026-08-26 10:29:33 +00:00
e958bb5c6b Stop pretending Vercel is an option
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m51s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m15s
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>
2026-08-21 11:02:40 +02:00
77d9a95f7f Run the database in a container of our own
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m25s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m9s
Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.

Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.

What the platform supplied, deploy/db-init now does:

  - alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
    RLS policies; a role that bypasses them would leave everything
    working while showing too much, and nobody would notice.
  - pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
    used nowhere — no column default, no function calls uuid_generate_*.
  - anon, authenticated and service_role as NOLOGIN placeholders. No
    policy names them; they only carry grants the platform handed out,
    and a data dump referencing them would fail to restore without them.
  - A stub `auth` schema. The end state needs none of it — checked: no
    foreign key, no policy, no column default refers to it. The June
    2026 migrations do, and rewriting those would be falsifying history;
    they describe what was true then.

The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.

Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.

psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.

SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.

The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.

Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:34:08 +02:00
d574d3c9d6 Aim the deploy at the runner that exists
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m28s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m11s
Deploy / Migrationen und Container (push) Failing after 4s
The workflow asked for a `self-hosted` label. No runner on the instance
offers one, so the run would have sat in "Waiting" forever — no error, no
message, nothing to notice. The existing global runner elycon-runner-01
offers `docker` and `ubuntu-latest`, so both workflows now ask for
`ubuntu-latest`, the same label ci.yml already used.

That correction exposed a second thing the first version glossed over.
act_runner starts a container per job; mounting the Docker socket into
the *runner* does not put it in the *job*. Whether this job can reach the
host's daemon depends on the runner's config.yaml, which is not visible
from here — and the runner is global, so changing it affects every
repository on the instance, not just this one.

Rather than guess, the workflow now measures it in its first step and
fails with the fix if it cannot: which config lines to add for the
socket, or that SSH is the other way. Without that, the run would have
died three steps later on a message nobody could act on.

Both branches of the check were exercised: docker absent prints the
first message, docker present with no reachable daemon the second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:15:49 +02:00
fc0989debb Deploy from a push, and start keeping track of migrations
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m52s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m13s
Deploy / Migrationen und Container (push) Has been cancelled
A push to master now builds and restarts the application on the server:
a Gitea Actions workflow on a self-hosted runner writes .env from the
repository secrets, applies pending migrations, rebuilds the compose
stack against the host's Docker daemon, and waits for the container's
healthcheck before calling the run green. Without that last step a
deploy counts as successful the moment the container *starts*, even if
the app inside it dies immediately.

Switching migrations on automatically turned up something that had to be
fixed first: supabase_migrations.schema_migrations did not exist at all.
Every one of the 65 migrations was unrecorded, because they have been
applied by hand all along. An automatic `db push` would therefore have
replayed all 65 against the live database — initial_schema and the OM
cutover included. The database was checked against a spread of
migrations first (it is at head), then baselined: all 65 recorded as
applied without executing them.

The runner is scripts/migrate.mjs rather than the Supabase CLI. It needs
only `pg`, which the project already ships, instead of downloading a CLI
whose version drifts independently of this repository; and it does one
thing — the missing files, in order, each in its own transaction — where
`db push` also diffs schemas and may do more than that. Bookkeeping goes
in the same table in the same shape the CLI uses, so `supabase db push`
from a workstation still works and still skips what already ran.

The workflow lives in .github/workflows, not .gitea/. Gitea reads
.gitea/workflows and falls back to .github/workflows only when the
former is absent — creating .gitea/ would have silently switched off
ci.yml, with the run simply never appearing.

Verified: both workflow files parse; the secret check names what is
missing and refuses; values starting with "-" or containing "=" survive
being written to .env; and the runner was exercised against the real
database with a throwaway migration — applied once, skipped on a second
run, and on a deliberate syntax error rolled back whole, recording
nothing. Both probes were removed; the count is back to 65.

Not verified: nothing has run on an actual Gitea runner — none is
registered yet. DEPLOYMENT.md §5 covers registering one.

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

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

  20260818_2884_Aigner-Manuel_Onboarding-Checklist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:40:02 +02:00
bd990b7f2c Merge branch 'feat/sap-om-org-model'
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m9s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m20s
2026-08-17 12:42:38 +02:00
4c5bfc223f README.md aktualisiert
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m5s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m21s
2026-08-03 07:52:05 +00:00
285 changed files with 14756 additions and 4715 deletions

View File

@@ -15,6 +15,3 @@ npm-debug.log*
*.tsbuildinfo
.scratch_*
.scratch_shots
supabase/.branches
supabase/.temp
supabase/snippets

View File

@@ -1,23 +1,42 @@
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
# ── Datenbank ────────────────────────────────────────────────────────
#
# Die Datenbank läuft als eigener Container (Dienst `db` in
# docker-compose.yml). Diese drei Werte richten ihn ein:
#
# POSTGRES_PASSWORD das des Verwalters (postgres) — nur für Migrationen,
# Sicherungen und Wartung
# APP_DB_PASSWORD das der Anwendungsrolle alpenwerk_app
# POSTGRES_DB Name der Datenbank; Vorgabe alpenwerk
#
# Beide Passwörter selbst erzeugen: `openssl rand -base64 24`
#
# Sie werden nur beim **allerersten** Start ausgewertet, solange das
# Datenverzeichnis leer ist. Ein späterer Wechsel braucht ein
# `alter role … password …` in der laufenden Datenbank.
POSTGRES_PASSWORD=
APP_DB_PASSWORD=
POSTGRES_DB=alpenwerk
POSTGRES_USER=postgres
# Womit die Anwendung sich verbindet. Im Compose-Netz heisst die Datenbank
# `db`; von aussen ist sie nicht erreichbar, es gibt bewusst keinen Port.
#
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
#
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste
# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die
# Anwendung nur noch mit „max clients reached".
DATABASE_URL=
# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI).
DATABASE_SSL=
# `alpenwerk_app` wird in deploy/db-init genau so angelegt.
DATABASE_URL=postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk
# Im Compose-Netz läuft Postgres ohne TLS — die Verbindung verlässt den
# Server nicht. Bei einer Datenbank ausserhalb diesen Wert entfernen.
DATABASE_SSL=false
# Verbindungen im Pool; Vorgabe 10.
DATABASE_POOL_MAX=
# Der **direkte** Zugang für Migrationen (scripts/migrate.mjs). Als Verwalter,
# weil Migrationen Schemaänderungen vornehmen, die die Anwendungsrolle nicht
# darf. Bei Betrieb über docker compose wird er nicht gebraucht — der Dienst
# `migrate` setzt ihn selbst aus POSTGRES_PASSWORD zusammen.
MIGRATE_DATABASE_URL=
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
@@ -38,7 +57,9 @@ AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id
# Auth.js die Rückruf-Adresse aus den Request-Headern.
AUTH_URL=
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
# Gemeinsames Geheimnis, mit dem sich der nächtliche Lauf ausweist: der
# `cron`-Container schickt es als `Authorization: Bearer <wert>` an
# /api/cron/apply-pending-changes. Ohne angemeldete Person gibt es keine
# Sitzung, an der die Route den Aufruf prüfen könnte.
# Erzeugen mit: openssl rand -hex 32
CRON_SECRET=

View File

@@ -29,25 +29,45 @@ jobs:
- name: Typecheck
run: npm run typecheck
# Catches the drift that `tsc` cannot: lib/supabase/types.ts is
# hand-written, so a migration adding a column leaves it silently stale.
# Fängt die Abweichung, die `tsc` nicht sehen kann: lib/types.ts wird von
# Hand gepflegt, eine Migration mit einer neuen Spalte lässt sie also
# stillschweigend veralten.
- name: Schema/Typen-Abgleich
run: npm run types:check
- name: Unit- und Komponententests
run: npm test
# Ohne Umgebungsvariablen: seit dem Wegfall der Browser-Anbindung wird
# zur Bauzeit nichts mehr aus der Umgebung gelesen, und das Abbild ist
# für jede Umgebung dasselbe.
- name: Build
run: npm run build
env:
# Read at module scope by the Supabase browser client, so the build
# needs them present — never the real project's values.
NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY: build-time-placeholder
integration:
name: Integrationstests (echtes Postgres)
migrationen:
name: Migrationen auf leerer Datenbank
runs-on: ubuntu-latest
# Derselbe Stand wie im Betrieb. Ein eigener Dienst statt einer fremden
# Plattform — die Datenbank gehört seit dem Umzug zum Projekt.
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_PASSWORD: ci
POSTGRES_DB: alpenwerk
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
MIGRATE_DATABASE_URL: postgresql://postgres:ci@postgres:5432/alpenwerk
DATABASE_SSL: "false"
steps:
- uses: actions/checkout@v4
@@ -58,40 +78,117 @@ jobs:
- run: npm ci
- uses: supabase/setup-cli@v1
with:
version: latest
# Applies every migration to a fresh database — which also means a
# migration that cannot be replayed from scratch fails here rather than
# on a restore or a new environment.
- name: Supabase starten
run: supabase start
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app
# expects them under its own names. DATABASE_URL ist der direkte
# Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und
# vor allem der Nachweis zum Sitzungskontext.
- name: Testumgebung schreiben
# Das Abbild des Runners bringt keinen Postgres-Client mit; der
# Schritt darunter ruft aber psql. Ohne das bricht er mit
# "psql: command not found" ab, bevor die erste Datei laeuft.
- name: psql bereitstellen
run: |
supabase status -o env \
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
--override-name db.url=DATABASE_URL \
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \
| tr -d '"' > .env.test.local
# Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem.
echo "DATABASE_SSL=false" >> .env.test.local
grep -q '^DATABASE_URL=' .env.test.local \
|| { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; }
apt-get update -qq
apt-get install -y -qq --no-install-recommends postgresql-client
psql --version
- name: Seed
run: node --env-file=.env.test.local supabase/seed.ts
# Was das Schema von der Umgebung erwartet: die Anwendungsrolle ohne
# BYPASSRLS, zwei Erweiterungen und die Attrappe des `auth`-Schemas, ohne
# die sich die Migrationen von Juni 2026 nicht abspielen lassen.
- name: Rollen, Erweiterungen, auth-Attrappe
env:
PGPASSWORD: ci
APP_DB_PASSWORD: ci-anwendungsrolle
run: |
for f in deploy/db-init/*.sql; do
echo "── $f"
psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -f "$f"
done
- name: Integrationstests
run: npm run test:integration
# Der eigentliche Zweck dieses Jobs: jede Migration muss sich auf einer
# leeren Datenbank abspielen lassen. Eine, die das nicht kann, fällt hier
# auf — und nicht beim Wiederherstellen einer Sicherung oder beim
# Aufsetzen einer neuen Umgebung.
- name: Migrationen einspielen
run: node scripts/migrate.mjs
- name: Supabase-Logs bei Fehlschlag
if: failure()
run: supabase status && docker ps -a
# Zweiter Lauf: die Buchführung muss greifen. Meldet er etwas anderes als
# „nichts anzuwenden", verbucht der Läufer nicht richtig — und ein
# Ausrollen spielte Migrationen doppelt ein.
- name: Zweiter Lauf ist ein Nichts
run: |
ausgabe=$(node scripts/migrate.mjs)
echo "$ausgabe"
echo "$ausgabe" | grep -q "^Nichts anzuwenden" \
|| { echo "Der zweite Lauf wollte erneut anwenden — die Buchführung greift nicht."; exit 1; }
# Dass die Migrationen durchlaufen, heisst nicht, dass das Ergebnis
# stimmt. Bis hierher prüft dieser Job nur, dass keine Datei abbricht —
# und genau daran ist ein Fehler vorbeigekommen: vier Tabellen aus dem
# August (Kostenstellen, On-/Offboarding) standen ohne Zeilenschutz da,
# weil sie sich auf den Ereignis-Trigger `ensure_rls` verliessen, den
# eine spätere Migration erst anlegt. Alle Dateien liefen sauber durch,
# die Policies standen im Katalog, und ausgewertet wurde keine davon.
#
# Deshalb wird ab hier der **Zustand** geprüft, nicht der Durchlauf.
# Nachgezogen hat das 20260908100000; dort steht dieselbe Gegenprobe.
# Sie läuft aber nur einmal, beim Anwenden jener Datei. Der Schritt hier
# läuft bei jedem Push gegen eine frisch aufgebaute Datenbank und fängt
# deshalb auch die *nächste* Tabelle, die es wieder vergisst.
- name: Zugriffsschutz nach dem Lauf
env:
PGPASSWORD: ci
run: |
set -euo pipefail
frage() {
psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -At -c "$1"
}
# relkind in ('r','p'): gewöhnliche und partitionierte Tabellen.
ohne_rls=$(frage "
select coalesce(string_agg(c.relname, ', ' order by c.relname), '')
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
and not c.relrowsecurity")
# RLS ohne Policy ist nicht offen, sondern leer — kein Leck, aber
# eine Tabelle, aus der nie etwas zurückkommt.
ohne_policy=$(frage "
select coalesce(string_agg(c.relname, ', ' order by c.relname), '')
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
and not exists (select 1 from pg_policy p where p.polrelid = c.oid)")
# Das Netz selbst: fehlt oder schläft es, ist die nächste neue
# Tabelle wieder ungeschützt.
netz=$(frage "
select count(*) from pg_event_trigger
where evtname = 'ensure_rls' and evtenabled <> 'D'")
fehler=0
if [ -n "$ohne_rls" ]; then
echo "Ohne Row Level Security: $ohne_rls"
fehler=1
fi
if [ -n "$ohne_policy" ]; then
echo "Mit Row Level Security, aber ohne jede Policy: $ohne_policy"
fehler=1
fi
if [ "$netz" != "1" ]; then
echo "Der Ereignis-Trigger ensure_rls fehlt oder ist abgeschaltet."
fehler=1
fi
if [ "$fehler" -ne 0 ]; then
echo
echo "Jede Tabelle in public braucht RLS und mindestens eine Policy."
echo "Nachziehen in einer neuen Migration, nicht in einer bereits angewendeten."
exit 1
fi
echo "Zugriffsschutz vollstaendig: jede Tabelle mit RLS und Policy, ensure_rls aktiv."
# Die Integrationstests brauchen einen befüllten Bestand; der Seed lag
# in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe
# README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare
# Datenbank von Hand, nicht hier.

191
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,191 @@
name: Deploy
# Warum diese Datei unter .github/workflows liegt und nicht unter .gitea/:
#
# Gitea Actions liest `.gitea/workflows`, **und nur wenn dieses Verzeichnis
# fehlt**, ersatzweise `.github/workflows`. Ein neu angelegtes `.gitea/`
# würde also ci.yml stillschweigend abschalten — der Lauf verschwände
# einfach, ohne Fehlermeldung. Solange beide Dateien hier liegen, sieht Gitea
# beide. (Auf GitHub bliebe dieser Workflow hängen: es gibt dort keinen
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
# git.elycon.solutions.)
# Nur von Hand auslösbar (Actions → Deploy → „Run workflow").
#
# Lief einmal bei jedem Push auf master. Das ist zurückgenommen: die
# Voraussetzungen dafür sind nicht erfüllt — es sind keine Secrets hinterlegt,
# und ob der Job-Container den Docker-Dienst des Hosts überhaupt erreicht, ist
# ungeprüft. Bei jedem Push zu scheitern erzieht dazu, rote Läufe zu
# übersehen; dann lieber gar nicht starten.
#
# Nach dem Umzug auf den eigenen db-Container stimmt hier ausserdem noch
# nicht alles: die Secrets unten kennen die neuen Werte (POSTGRES_PASSWORD,
# APP_DB_PASSWORD) nicht, und Migrationen laufen jetzt über den Dienst
# `migrate` statt über einen eigenen Node-Schritt. Wer das wieder scharf
# schaltet, muss beides nachziehen.
on:
workflow_dispatch:
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose
# up` abgeschnitten zu werden ist der eine Zustand, den man nicht will.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
name: Migrationen und Container
# `ubuntu-latest`, weil der vorhandene Runner (elycon-runner-01) genau
# diese Bezeichnung anbietet — neben `docker`. Ein Label, das kein Runner
# führt, lässt den Lauf wortlos in der Warteschlange stehen: kein Fehler,
# keine Meldung, nur „Waiting".
runs-on: ubuntu-latest
steps:
# ── Vorprüfung ──────────────────────────────────────────────────
#
# Dieser Job läuft in einem Container, den der Runner startet — nicht
# auf dem Server selbst. Ob er den Docker-Dienst des **Hosts** erreicht,
# hängt davon ab, wie der Runner konfiguriert ist; von aussen ist das
# nicht zu sehen. Statt es zu raten, wird es hier gemessen und im
# Fehlerfall gesagt, was fehlt — sonst scheitert der Lauf erst drei
# Schritte später an einer Meldung, die niemandem weiterhilft.
- name: Erreicht dieser Job den Docker-Dienst des Servers?
run: |
set -u
if ! command -v docker >/dev/null 2>&1; then
echo 'Im Job-Container gibt es kein "docker".'
echo
echo "Zwei Wege:"
echo " a) Im Runner (config.yaml) ein Abbild verwenden, das die Docker-CLI"
echo " mitbringt, und den Socket durchreichen — siehe DEPLOYMENT.md §5."
echo " b) Statt lokal per SSH auf den Server ausrollen."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Docker-CLI vorhanden, aber kein Dienst erreichbar."
echo
echo "Dem Job-Container fehlt der Socket des Hosts. In der config.yaml des"
echo "Runners:"
echo " container:"
echo " options: -v /var/run/docker.sock:/var/run/docker.sock"
echo " valid_volumes: [/var/run/docker.sock]"
echo "Danach den Runner neu starten. Alternative: Deploy per SSH."
exit 1
fi
docker compose version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# Nur was der Migrationsläufer braucht (`pg`), nicht der ganze Baum:
# gebaut wird die Anwendung im Container, nicht hier.
- name: Abhängigkeiten
run: npm ci --omit=dev --ignore-scripts
# ── .env aus den Gitea-Secrets ──────────────────────────────────
#
# Nicht die .env vom Server lesen: der Job läuft in einem eigenen
# Container, und dessen Dateisystem ist nicht das des Hosts. Die Werte
# kommen deshalb aus den Repository-Secrets und werden hier
# zusammengesetzt. Sie landen in keinem Abbild — docker compose reicht
# sie zur Laufzeit an den Container weiter.
- name: .env schreiben
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL }}
AUTH_MICROSOFT_ENTRA_ID_ID: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ID }}
AUTH_MICROSOFT_ENTRA_ID_SECRET: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_SECRET }}
AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ISSUER }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
run: |
set -euo pipefail
fehlend=""
for name in DATABASE_URL MIGRATE_DATABASE_URL AUTH_SECRET AUTH_URL \
AUTH_MICROSOFT_ENTRA_ID_ID AUTH_MICROSOFT_ENTRA_ID_SECRET \
AUTH_MICROSOFT_ENTRA_ID_ISSUER CRON_SECRET; do
eval "wert=\${$name:-}"
[ -n "$wert" ] || fehlend="$fehlend $name"
done
if [ -n "$fehlend" ]; then
echo "Diese Secrets fehlen im Repository (Settings → Actions → Secrets):$fehlend"
exit 1
fi
# printf statt echo: ein Wert, der mit - beginnt, wäre sonst ein Schalter.
{
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
printf 'AUTH_URL=%s\n' "$AUTH_URL"
printf 'AUTH_MICROSOFT_ENTRA_ID_ID=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ID"
printf 'AUTH_MICROSOFT_ENTRA_ID_SECRET=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_SECRET"
printf 'AUTH_MICROSOFT_ENTRA_ID_ISSUER=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ISSUER"
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
} > .env
chmod 600 .env
# ── Migrationen ─────────────────────────────────────────────────
#
# Vor dem Neustart, nicht danach: der neue Code erwartet das neue
# Schema. Umgekehrt liefe die neue Anwendung kurz gegen das alte und
# fiele über fehlende Spalten.
#
# Erst zeigen, was ansteht — das steht dann im Protokoll des Laufs, auch
# wenn danach etwas schiefgeht.
- name: Ausstehende Migrationen zeigen
env:
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
run: node scripts/migrate.mjs --dry-run
- name: Migrationen anwenden
env:
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
run: node scripts/migrate.mjs
# ── Container ───────────────────────────────────────────────────
#
# Läuft gegen den Docker-Dienst des Hosts (der Runner-Container hat
# dessen Socket eingehängt). Der Projektname wird ausdrücklich gesetzt:
# sonst leitet ihn Compose vom Verzeichnisnamen ab, und der ist im
# Arbeitsverzeichnis des Runners ein anderer als bei der ersten
# Installation von Hand — es entstünde ein zweiter Stapel daneben,
# während der alte weiterläuft.
- name: Bauen und starten
env:
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
run: |
set -euo pipefail
docker compose build
docker compose up -d --remove-orphans
# ── Nachweis ────────────────────────────────────────────────────
#
# Ohne diesen Schritt gilt ein Deploy als erfolgreich, sobald der
# Container *gestartet* ist — auch wenn die Anwendung darin sofort
# abstürzt. Gewartet wird auf den Healthcheck aus dem Dockerfile
# (GET /login), nicht auf „läuft".
- name: Warten, bis die Anwendung antwortet
env:
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
run: |
set -euo pipefail
for versuch in $(seq 1 30); do
zustand=$(docker compose ps --format '{{.Health}}' app | head -1)
case "$zustand" in
healthy) echo "Gesund nach $versuch Versuchen."; exit 0 ;;
unhealthy) echo "Container meldet unhealthy."; break ;;
esac
sleep 4
done
echo "Anwendung ist nicht gesund geworden. Letzte Ausgaben:"
docker compose ps
docker compose logs --tail 80 app
exit 1
- name: Aufräumen
if: always()
run: rm -f .env

21
.gitignore vendored
View File

@@ -34,26 +34,21 @@ yarn-error.log*
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# supabase CLI local dev (generated, project-machine-specific)
/supabase/.branches
/supabase/.temp
/supabase/snippets
# scratch output of `npm run types:generate`, for comparing against the
# hand-written lib/supabase/types.ts — never itself imported
/lib/supabase/types.generated.ts
# local scratch scripts/screenshots (ad-hoc verification against a real or
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
# Kladden und Bildschirmfotos aus der Handprüfung — können Personendaten
# oder Zugangsdaten enthalten; nie committen.
.scratch_*
/.scratch_shots/
# Datenbank-Sicherungen (enthalten Personendaten) – nie committen.
.backups/
# Datenabzuege aus dem Umzug – enthalten Personendaten
# Datenbank-Abzuege – enthalten Personendaten, nie committen.
*.sql.gz
/*.sql

View File

@@ -1,89 +1,34 @@
# Deployment
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also
jederzeit möglich.
Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server,
zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt
keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit
aus `.env`.
| | passt, wenn |
|---|---|
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg |
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll |
**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen),
die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach
ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe
[docs/entra-sso.md](docs/entra-sso.md)).
## Vercel
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
```bash
npx vercel login
npx vercel link
npx vercel --prod
```
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
Personen ist das eher Vorteil als Nachteil.
### b) Zusätzlich nach GitHub spiegeln
```bash
git remote add github git@github.com:<konto>/alpenwerk-hr.git
git push github feat/sap-om-org-model
```
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
### Danach
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
nicht nötig, Vercel setzt den Host selbst.
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
vielen tausend Zeilen ansteht.
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
verbraucht.
## Deployment mit Docker
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
einem eigenen Linux-Server läuft.
Zu klären, bevor es losgeht: die Umgebungsvariablen aus
[Abschnitt 1](#1-env-anlegen), die Umleitungs-URI in der
Entra-Registrierung, und dass eine neue Person nach ihrer ersten Anmeldung
eine `profiles`-Zeile braucht (siehe [docs/entra-sso.md](docs/entra-sso.md)).
## Was wird containerisiert – und was nicht
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über
`DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein
Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS,
Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die
Migrations- und Entwicklungsumgebung, kein Teil des Deployments.
- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank**
(Dienst `db`, `postgres:17-alpine`). Beide zusammen in
`docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`.
- **Die Datenbank gehört zum Projekt.** Was eine gehostete Plattform sonst
beisteuert, bringt `deploy/db-init/` mit: die Anwendungsrolle ohne
BYPASSRLS, die zwei benutzten Erweiterungen und eine Attrappe des
`auth`-Schemas, die nur die Migrationen von Juni 2026 brauchen.
- Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech),
genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht
zu starten. Die Anwendung merkt keinen Unterschied.
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
keinen Anmeldedienst, der mit ausgerollt werden müsste.
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml`
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
und demselben Bearer-Secret, das die Route bereits erwartet.
- **Der nächtliche Lauf** ist ein eigener Container (`cron`): täglich 03:00
Uhr ruft er `/api/cron/apply-pending-changes` auf, um fällige Versetzungen,
Beförderungen, Karenz- und Reorg-Änderungen zu übernehmen. Ausgewiesen wird
der Aufruf über `CRON_SECRET` — es gibt dabei keine angemeldete Person.
## Betrieb im Firmennetz — zwei Dinge vorab
@@ -97,12 +42,13 @@ nichts, ausgehend zwingend:
| Ziel | Wofür | Ohne das |
|---|---|---|
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL-
Instanz im selben Netz statt Supabase.
abgeschotteten Netz, braucht es dafür einen Proxy.
Die Datenbank taucht hier nicht mehr auf: sie läuft als Container daneben, im
selben Compose-Netz. Nach aussen geht dafür nichts.
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
@@ -120,11 +66,9 @@ bei zwanzig nicht.
## Voraussetzungen
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
enthaltene `docker compose`) auf dem Zielserver.
- Ein bestehendes Supabase-Projekt mit den Migrationen aus
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
bisher).
- Docker + Docker Compose (v2, das in Docker Engine enthaltene
`docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql;
was gebraucht wird, kommt aus Containern.
## 1. `.env` anlegen
@@ -136,8 +80,10 @@ Werte eintragen:
| Variable | Woher |
|---|---|
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
| `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters |
| `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` |
| `DATABASE_URL` | `postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk`. Die Rolle darf **kein** `BYPASSRLS` haben — `deploy/db-init` legt sie genau so an |
| `DATABASE_SSL` | `false` im Compose-Netz; die Verbindung verlässt den Server nicht |
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
@@ -190,6 +136,29 @@ Build-Argumente braucht es dabei **keine**: das Abbild enthält keine
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
Dasselbe Abbild läuft damit in Test und Produktion.
### Bei einer leeren Datenbank
Der `db`-Container legt beim ersten Start Rollen und Erweiterungen an; das
Schema kommt danach aus den Migrationen:
```bash
docker compose up -d db
docker compose run --rm migrate # legt Tabellen, Funktionen, Policies an
docker compose up -d --build
```
`migrate` steht im Profil `tools` und läuft bei `docker compose up` nicht mit.
Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker.
### Was am `auth`-Schema übrigbleibt
`deploy/db-init/01-auth-attrappe.sql` legt ein leeres `auth`-Schema an. Es wird
im Betrieb **nicht** gebraucht: kein Fremdschlüssel, keine Policy, keine
Spaltenvorgabe verweist darauf (geprüft). Gebraucht wird es nur beim Abspielen
der Migrationen von Juni 2026, die damals noch an `auth.users` hingen. Die
Alternative wäre, jene Dateien umzuschreiben — also Geschichte zu fälschen: sie
beschreiben, was damals galt.
## 4. Reverse Proxy + HTTPS
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
@@ -219,10 +188,133 @@ Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der
Container sieht — und das ist hinter dem Proxy nicht der Name, den der
Browser benutzt hat.
## 5. Updates ausrollen
## 5. Automatisch ausrollen bei einem Push (Gitea Actions)
Ein Push auf `master` baut und startet die Anwendung auf dem Server neu.
Zuständig ist [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml).
> **Warum `.github/` und nicht `.gitea/`:** Gitea Actions liest
> `.gitea/workflows` — und nur wenn dieses Verzeichnis **fehlt**, ersatzweise
> `.github/workflows`. Ein neu angelegtes `.gitea/workflows/` würde also
> `ci.yml` stillschweigend abschalten. Solange beide Dateien unter `.github/`
> liegen, sieht Gitea beide.
### a) Der Runner
Verwendet wird der bestehende, globale Runner **`elycon-runner-01`** (act_runner
v0.2.11, Labels `docker` und `ubuntu-latest`). Ein eigener ist nicht nötig.
Beide Workflows laufen deshalb auf `ubuntu-latest`. **Ein Label, das kein
Runner führt, lässt den Lauf wortlos in der Warteschlange stehen** — kein
Fehler, keine Meldung, nur „Waiting". Das ist der häufigste Grund, warum
scheinbar nichts passiert.
#### Der Job läuft im Container, nicht auf dem Server
Das ist die Stelle, an der es klemmen kann. act_runner startet je Job einen
eigenen Container; der Docker-Socket des **Hosts** ist darin nur, wenn der
Runner so konfiguriert ist:
```yaml
# config.yaml des Runners
container:
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
```
Zusätzlich muss das Job-Abbild die Docker-CLI mitbringen (die üblichen
`runner-images`/`catthehacker`-Abbilder tun das).
Der erste Schritt des Deploy-Workflows prüft genau das und bricht mit einer
Anleitung ab, wenn etwas fehlt — statt drei Schritte später an einer Meldung zu
scheitern, mit der niemand etwas anfangen kann.
> Der durchgereichte Socket gibt dem Job faktisch Wurzelrechte auf dem Host.
> Bei einem Deploy-Runner ist das der Zweck der Übung, aber es heisst auch: wer
> in dieses Repository schreiben darf, darf auf diesem Server alles. Bei einem
> **globalen** Runner gilt das für jedes Repository der Instanz, das denselben
> Socket benutzt. Ist das zu weit gefasst, ist der Weg über SSH der richtige:
> ein Schlüssel als Repository-Secret, der nur auf den einen Server und nur auf
> das Deploy-Verzeichnis zeigt.
### b) Secrets hinterlegen
Gitea → Repository → *Settings* → *Actions* → *Secrets*. Dieselben Werte wie in
[Abschnitt 1](#1-env-anlegen); der Workflow baut daraus zur Laufzeit die `.env`
und löscht sie danach wieder.
| Secret | Anmerkung |
|---|---|
| `DATABASE_URL` | Verbindung der Anwendungsrolle — die benutzt die Anwendung |
| `MIGRATE_DATABASE_URL` | Zugang als Verwalter — nur für die Migrationen |
| `AUTH_SECRET` | |
| `AUTH_URL` | z. B. `https://hr.elycon.solutions` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | |
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | |
| `CRON_SECRET` | |
Warum zwei Verbindungsstrings: der Transaktions-Pooler bricht bei einer
Migration ab, die mehrere Anweisungen in einer Transaktion bündelt. Migrationen
gehören über die direkte Verbindung, der laufende Betrieb über den Pooler.
Zusätzlich unter *Variables* (kein Secret, nur eine Einstellung):
| Variable | Vorgabe | Wofür |
|---|---|---|
| `COMPOSE_PROJECT_NAME` | `alpenwerk-hr` | Muss zum bestehenden Stapel passen — sonst entsteht ein zweiter daneben |
Den bestehenden Namen zeigt auf dem Server:
```bash
docker compose ls
```
### c) Was der Lauf tut
1. `.env` aus den Secrets schreiben (fehlt eines, bricht er mit Namen ab).
2. **Migrationen anwenden** — erst `--dry-run` fürs Protokoll, dann echt.
3. `docker compose build` und `up -d` gegen den Docker-Dienst des Hosts.
4. Auf `healthy` warten (bis zu zwei Minuten). Wird die Anwendung nicht gesund,
schlägt der Lauf fehl und hängt die letzten 80 Logzeilen an.
5. `.env` wieder löschen.
Der Workflow ist **nicht** an `ci.yml` gekoppelt: ein Push auf `master` rollt
aus, auch wenn die Tests parallel noch laufen. Soll erst nach grünen Tests
ausgerollt werden, gehört der `deploy`-Job mit `needs: [check]` in `ci.yml` —
dann allerdings braucht der Runner auch das Label, mit dem `check` läuft.
## 5a. Migrationen
Eingespielt werden sie von [`scripts/migrate.mjs`](scripts/migrate.mjs) —
demselben Läufer, den auch der Deploy benutzt:
```bash
node --env-file=.env scripts/migrate.mjs --dry-run # was stünde an
node --env-file=.env scripts/migrate.mjs # anwenden
```
Buch geführt wird in `migrationen.schema_migrations`: eine Zeile je
angewendeter Datei, mit ihrem Text. Der Läufer wendet nur an, was dort
fehlt.
> **Einmalig, im August 2026 bereits erledigt:** Die Migrationen wurden bis
> dahin von Hand eingespielt, die Buchführungstabelle existierte gar nicht. Ein
> automatischer Lauf hätte deshalb alle 65 Dateien erneut gegen die
> produktive Datenbank gespielt — inklusive `initial_schema` und der
> OM-Umstellung. Vor dem Einschalten wurde einmal
> `node scripts/migrate.mjs --baseline` gefahren: das verbucht alles Vorhandene
> als angewendet, **ohne es auszuführen**. Wer eine weitere Umgebung aufsetzt,
> deren Datenbank schon steht, braucht denselben Schritt.
## 5b. Updates von Hand ausrollen
Falls der Runner nicht läuft oder ein Stand ausser der Reihe gebraucht wird:
```bash
git pull
node --env-file=.env scripts/migrate.mjs
docker compose build
docker compose up -d
```
@@ -231,13 +323,8 @@ Kurzer Downtime-Moment beim Neustart des `app`-Containers ist bei dieser
Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments
wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).
Datenbank-Migrationen (`supabase/migrations/*.sql`) werden weiterhin über
die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom
App-Deployment:
```bash
supabase db push
```
Der Migrationsschritt steht bewusst **vor** dem Neubau: der neue Code erwartet
das neue Schema, und umgekehrt liefe die neue Anwendung kurz gegen das alte.
## 6. Hinweis bei mehreren Replicas
@@ -257,6 +344,22 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
## Troubleshooting
- **Push gemacht, aber kein Lauf startet:** meist die Labels. Der Lauf steht
dann in Gitea unter *Actions* als „Waiting" — ohne Fehlermeldung, weil kein
Runner das verlangte Label anbietet. Beide Workflows verlangen
`ubuntu-latest`; `elycon-runner-01` führt es. Welche Labels registriert sind,
zeigt Gitea → *Site Administration* → *Actions* → *Runners*.
- **Deploy läuft, aber es entsteht ein zweiter Stapel:** `COMPOSE_PROJECT_NAME`
passt nicht zum bestehenden. Auf dem Server `docker compose ls` — der dort
gelistete Name gehört als Variable ins Repository. Bis dahin läuft die alte
Instanz weiter und beansprucht Port 3000; die neue scheitert daran.
- **`Cannot connect to the Docker daemon` im Deploy:** dem Runner-Container
fehlt der Socket. Er braucht `-v /var/run/docker.sock:/var/run/docker.sock`.
- **Migration schlägt im Deploy fehl:** der Lauf bricht ab, bevor Container
angefasst werden — die alte Version läuft also weiter. Die fehlgeschlagene
Datei wurde vollständig zurückgerollt und **nicht** verbucht; nach der
Korrektur reicht ein erneuter Push.
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
Entra-Registrierung muss exakt
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
@@ -269,9 +372,6 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
Supabase Port 6543).
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im

View File

@@ -1,4 +1,4 @@
# Manner HR Master
# Alpenwerk HR Master
Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur
(Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/
@@ -24,11 +24,13 @@ auf explizit aktivierte HR-Benutzer:innen beschränkt (siehe
`node_modules/next/dist/docs/` konsultieren; sie sind maßgeblich, ältere
Anleitungen im Netz beschreiben teils überholte APIs.
- React 19, TypeScript
- Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in
Supabase, nicht im Next.js-Prozess.
- PostgreSQL, angesprochen über Kysely und `pg` — die Datenhaltung liegt in
der Datenbank, nicht im Next.js-Prozess. Der Zugriff läuft ausschliesslich
über `withUser()` (`lib/db/`), das den Sitzungskontext setzt.
- Auth.js gegen Microsoft Entra ID — keine eigenen Passwörter.
- Tailwind CSS v4
- Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests
(gegen ein lokales Supabase)
(gegen eine erreichbare Datenbank)
## Setup
@@ -38,14 +40,14 @@ cp .env.example .env.local # Werte eintragen, siehe unten
npm run dev
```
Für lokale Supabase-Entwicklung (statt gegen ein Cloud-Projekt):
Für eine lokale Datenbank genügt der Container aus `docker-compose.yml`:
```bash
supabase start # startet lokalen Postgres/Auth/Studio-Stack
docker compose up -d db
docker compose run --rm migrate # spielt db/migrations/ ein
```
`supabase/config.toml` und `.env.test.local` sind bereits auf die
Standard-Ports der lokalen Supabase-CLI abgestimmt.
Danach zeigt `DATABASE_URL` in `.env.local` auf diese Datenbank.
## Umgebungsvariablen
@@ -77,9 +79,10 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
| `npm run lint` | ESLint (`eslint-config-next`, Flat Config) |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run test` | Vitest, Unit-Tests (`tests/unit/**`) |
| `npm run test:integration` | Vitest gegen eine echte (lokale) Supabase-Instanz — braucht `supabase start` und `.env.test.local` |
| `npm run test:integration` | Vitest gegen eine erreichbare Datenbank; überspringt sich ohne `DATABASE_URL` |
| `npm run test:e2e` | Playwright |
| `npm run check` | lint + typecheck + test + build in Folge |
| `npm run migrate` | Ausstehende Migrationen einspielen (`--dry-run`, `--baseline`) |
| `npm run check` | lint + typecheck + types:check + test + build in Folge — dieselben Schritte wie der CI-Job „check" |
## Sicherheitsprinzipien
@@ -110,52 +113,50 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
`/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte
Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`).
- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron
sendet `Authorization: Bearer <CRON_SECRET>` automatisch, wenn
`CRON_SECRET` in den Projekt-Env-Vars gesetzt ist.
- **Außerhalb von Vercel (Docker):** kein Vercel Cron verfügbar — siehe
[`DEPLOYMENT.md`](DEPLOYMENT.md) für den Cron-Sidecar-Container, der
denselben Endpoint mit demselben Schema aufruft.
- Den Zeitplan hält der `cron`-Container aus `docker-compose.yml`: täglich
03:00 Uhr, mit `Authorization: Bearer <CRON_SECRET>`.
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
## Supabase-Hinweise
## Datenbank
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
Fassung, aus der laufenden Datenbank erzeugt:
- Schema-Quelle der Wahrheit: `db/migrations/`. Menschlich lesbare Fassung,
aus der laufenden Datenbank erzeugt:
[`docs/datenkatalog.md`](docs/datenkatalog.md).
- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt)
bzw. `supabase start` + automatische Anwendung für lokale Entwicklung.
- `supabase/seed.ts` und `.env.test.local` sind nur für lokale
Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden.
- Migrationen einspielen: `npm run migrate`. Der Läufer wendet nur die
fehlenden Dateien an, jede in ihrer eigenen Transaktion, und führt darüber
Buch in `migrationen.schema_migrations`.
- `lib/types.ts` wird von Hand gepflegt. `npm run types:check` hält sie
Spalte für Spalte gegen die Migrationen.
## Testing
- `npm run test` — schnell, keine externen Abhängigkeiten, läuft in CI.
- `npm run test:integration` — braucht eine laufende lokale Supabase-Instanz
(`supabase start`) und `.env.test.local`; prüft RLS-Verhalten end-to-end
(siehe `tests/integration/authorization.test.ts` für das HR-Only-Zugriffs-
modell).
- `npm run test:integration` — braucht eine erreichbare Datenbank
(`DATABASE_URL`). Geprüft werden Regeln, die es zweimal gibt: einmal als
SQL, einmal als TypeScript. Ohne `DATABASE_URL` überspringen sich die
Dateien, statt mit einem Verbindungsfehler abzubrechen.
- `npm run test:e2e` — Playwright gegen einen laufenden Dev-/Preview-Server.
## Deployment
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates).
Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im
Vercel-Projekt setzen (siehe oben).
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md): Anwendung und Datenbank als Container,
Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates.
## Known TODOs vor Produktivbetrieb
- **Content-Security-Policy fehlt noch** (`next.config.ts` setzt bewusst
keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig
inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den
Supabase-Client stillschweigend zu brechen).
- **Lokale Scratch-Artefakte** (`.scratch_*`, `.scratch_shots/`) enthalten
Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und
liegen noch im Arbeitsverzeichnis. Sie sind jetzt über `.gitignore`
ausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und
bei Bedarf gelöscht werden.
- **Content-Security-Policy läuft im Nur-Bericht-Modus** (`next.config.ts`).
Erzwungen wird sie erst, wenn die Meldungen sauber sind — eine geratene,
erzwungene Richtlinie blendet die Anwendung für alle aus.
- **Seed für die Integrationstests fehlt.** Er lag in der abgelösten
Umgebung. Zwei der drei Integrationstests vergleichen Regeln über den
gesamten Bestand und brauchen dafür Daten; bis der Seed nachgezogen ist,
laufen sie nur gegen eine bereits befüllte Datenbank, nicht in der CI.
- **Sicherheitsprüfung des heutigen Aufbaus steht aus** — siehe
[`docs/security-review.md`](docs/security-review.md).
- **Bildschirmfotos unter `.scratch_shots/`** stammen aus einer früheren
Handprüfung. Sie sind über `.gitignore` ausgeschlossen; vor der Übergabe
durchsehen und löschen.
- **Kein granulareres Rollenmodell** — aktuell HR-only (alles-oder-nichts).
Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die
Erweiterung in eine neue Migration (`is_hr_user()`/RLS-Policies), nicht in

View File

@@ -1,15 +1,38 @@
"use server";
import { AuthError } from "next-auth";
import { signIn, signOut } from "@/auth";
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
// Austritt.
// Zwei Anmeldewege, und der zweite ist auf Zeit.
//
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere.
// **Entra ID ist der Hauptweg.** Was der Mandant vorgibt — Mehrfaktor,
// bedingter Zugriff, Sperrung beim Austritt — gilt nur auf diesem Weg. Ein
// zweiter Weg daneben hebelt all das aus, und deshalb stand hier bis zum
// 08.09.2026, dass es ihn bewusst nicht gibt.
//
// **Warum es ihn jetzt trotzdem gibt.** Fünf Mitarbeiterinnen des Kunden
// (@manner.com) sollen die Anwendung testen. Manner hat einen eigenen
// Entra-Mandanten; eine Gasteinladung müsste deren IT einrichten und dauert
// Wochen. Die Daten im System sind zu diesem Zeitpunkt synthetisch.
//
// **Was den Weg begrenzt** — nichts davon steht hier, alles in der Datenbank
// (Migration 20260908120000), weil eine Regel im Anwendungscode die Regel
// wäre, die sich umgehen lässt:
//
// • Zwang zum Wechsel beim ersten Mal; solange er aussteht, liefert
// is_hr_user() false und damit gibt keine einzige Policy eine Zeile her
// • sieben Tage Frist für einen nie benutzten Zugang
// • Sperre für 15 Minuten nach fünf Fehlversuchen
// • Enddatum 08.10.2026, als Prüfbedingung festgenagelt
// (chk_passwort_pfad_endet) — danach nimmt die Anmeldung kein Passwort
// mehr an, ohne dass sich jemand erinnern muss
//
// **Vor den echten Manner-Daten muss der Passwortpfad weg sein.** Das ist die
// Bedingung, unter der er entstanden ist, nicht eine Empfehlung.
//
// Die Herkunft muss hier nicht aus dem Request geholt werden: Auth.js baut die
// Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben Host. Ein
// untergeschobener Host läuft also weiterhin ins Leere.
export async function signInWithEntra() {
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
@@ -17,6 +40,47 @@ export async function signInWithEntra() {
await signIn("microsoft-entra-id", { redirectTo: "/" });
}
export type AnmeldeZustand = { fehler: string | null };
/**
* Anmeldung mit E-Mail und Passwort.
*
* Es gibt **eine** Fehlermeldung für jeden Fehlschlag. Ob die Adresse
* unbekannt, das Passwort falsch, das Konto gesperrt oder die Frist abgelaufen
* ist, steht im Protokoll und nicht auf dem Bildschirm: das Anmeldeformular
* steht offen im Internet, und eine Meldung, die zwischen „gibt es nicht" und
* „falsches Passwort" unterscheidet, ist ein Verzeichnis der Belegschaft.
* app_passwort_pruefen() nennt aus demselben Grund keinen Grund.
*/
export async function anmeldenMitPasswort(
_zustand: AnmeldeZustand,
formular: FormData
): Promise<AnmeldeZustand> {
const email = String(formular.get("email") ?? "").trim();
const passwort = String(formular.get("passwort") ?? "");
if (!email || !passwort) {
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
}
try {
await signIn("passwort", { email, passwort, redirectTo: "/" });
} catch (fehler) {
// Bei Erfolg wirft signIn die Weiterleitung von Next.js. Die darf hier
// **nicht** hängenbleiben, sonst endet die Anmeldung auf der Anmeldeseite,
// obwohl das Cookie längst gesetzt ist. Nur ein AuthError ist ein
// Fehlschlag; alles andere gehört weitergereicht.
if (fehler instanceof AuthError) {
return { fehler: "E-Mail-Adresse oder Passwort stimmt nicht." };
}
throw fehler;
}
// Unerreichbar — signIn kehrt bei Erfolg nicht zurück. Steht da, weil der
// Typprüfer einen Rückgabewert auf jedem Pfad verlangt.
return { fehler: null };
}
export async function logout() {
await signOut({ redirectTo: "/login" });
}

View File

@@ -4,7 +4,9 @@ import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
import { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types";
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload);
@@ -13,6 +15,44 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalid
return result;
}
/**
* Ist diese Personalnummer noch frei?
*
* ── Warum das trotz der Prüfung in der Datenbank hier steht ──────────
*
* `hire_employee` weist eine vergebene Nummer ab, und das bleibt die
* verbindliche Prüfung: zwischen dieser Frage und dem Anlegen können Sekunden
* liegen, und in denen kann jemand anderes dieselbe Nummer vergeben. Der
* Unique-Index ist die einzige Stelle, die das sicher ausschliesst.
*
* Nur kommt diese Abweisung ganz am Ende — nach Position, Vertrag,
* Angehörigen, Notfallkontakt, sechs Schritten Eingabe. Die Nummer steht im
* *ersten* Feld des *ersten* Schritts. Wer sie vertippt, erfährt es
* frühestens nach fünf Minuten und darf dann suchen, welche der Angaben
* gemeint war.
*
* Deshalb hier die frühe Auskunft und dort die Entscheidung. Es ist keine
* doppelte Wahrheit, sondern dieselbe Frage zu zwei Zeitpunkten — und wenn
* die späte Antwort einmal abweicht, gewinnt sie.
*
* Kein `ActionResult`: eine vergebene Nummer ist kein Fehler des Aufrufs,
* sondern die Antwort auf die Frage.
*/
export async function personalnummerVergeben(nummer: number): Promise<{ vergeben: boolean; name?: string }> {
if (!Number.isInteger(nummer) || nummer <= 0) return { vergeben: false };
return withUser(await currentUserId(), async (tx) => {
const treffer = await tx
.selectFrom("employees")
.select(["first_name", "last_name"])
.where("personnel_number", "=", nummer)
.executeTakeFirst();
// Der Name wird mitgegeben, weil „bereits vergeben" allein die Frage
// aufwirft, an wen — und die Antwort ist meistens der Grund: dieselbe
// Person ist schon angelegt, oder es war ein Zahlendreher.
return treffer ? { vergeben: true, name: `${treffer.first_name} ${treffer.last_name}` } : { vergeben: false };
});
}
export async function hireEmployee(payload: {
/**
* Wird eingegeben, nicht vergeben.
@@ -60,16 +100,31 @@ export async function hireEmployee(payload: {
emergency_contact_relation?: string;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
/** Form der Beschäftigung — chk_mitarbeiterart. Leer heisst „unverändert". */
mitarbeiterart?: string;
has_kuendigungsschutz?: boolean;
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
kuendigungsschutz_bis?: string | null;
/** Ebenfalls nur mit dem Kennzeichen — chk_kuendigungsschutz_grund. */
kuendigungsschutz_grund?: string | null;
kuendigungsschutz_ab?: string | null;
ist_beguenstigt_behindert?: boolean;
/** Nur mit dem Kennzeichen — chk_behinderung. Als Text, wie er aus dem Feld kommt. */
behinderung_grad?: string | null;
behinderung_ab?: string | null;
behinderung_bis?: string | null;
}): Promise<ActionResult & { employeeId?: string }> {
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
try {
const employeeId = await withUser(await currentUserId(), (tx) =>
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
);
const employeeId = await withUser(await currentUserId(), async (tx) => {
const id = (await callFunction(tx, "hire_employee", payload as Record<string, unknown>)) as string;
// In derselben Transaktion: eine Einstellung ohne Checkliste wäre eine
// halb erfasste Einstellung, und sie später nachzureichen hiesse, dass
// jemand daran denken muss.
await callFunction(tx, "start_onboarding", { employee_id: id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) });
return id;
});
revalidatePath("/employees");
revalidatePath("/");
revalidatePath("/positions");
@@ -83,9 +138,59 @@ export async function terminateEmployee(payload: {
employee_id: string;
exit_date: string;
exit_reason: string;
/**
* Freiwillig oder unfreiwillig — leer heisst „nicht erfasst".
*
* Eigene Angabe und nicht aus `exit_reason` abgeleitet: die einvernehmliche
* Auflösung kann von beiden Seiten ausgehen (siehe lib/beendigung.ts).
*/
austrittsart?: string;
note?: string;
}): Promise<ActionResult> {
// No Show ist kein Austritt im gewohnten Sinn — die Person hat nie
// angefangen. Dafür gibt es nichts offzuboarden: keinen IT-Zugang, der
// eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Die Liste
// entstünde leer und wäre ein Etikett ohne Inhalt.
if (payload.exit_reason === "No Show") {
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
try {
await withUser(await currentUserId(), async (tx) => {
await callFunction(tx, "terminate_employee", payload as unknown as Record<string, unknown>);
// In derselben Transaktion: ein Austritt ohne Checkliste wäre ein
// halb erfasster Austritt, und sie später anzulegen hiesse, dass
// jemand daran denken muss.
await callFunction(tx, "start_offboarding", {
employee_id: payload.employee_id,
item_keys: OFFBOARDING_PUNKTE.map((x) => x.key),
});
});
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
return { success: true };
}
/** Einen Punkt der Offboarding-Checkliste festhalten — Haken, Wert oder Kommentar. */
export async function setOffboardingTask(payload: {
employee_id: string;
item_key: string;
erledigt?: boolean;
wert?: string | null;
kommentar?: string | null;
}): Promise<ActionResult> {
return callRpc("set_offboarding_task", payload, [`/employees/${payload.employee_id}`]);
}
/** Legt die Offboarding-Checkliste nachträglich an — für Austritte von vor dieser Liste. */
export async function startOffboarding(employeeId: string): Promise<ActionResult> {
return callRpc(
"start_offboarding",
{ employee_id: employeeId, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key) },
[`/employees/${employeeId}`]
);
}
export async function transferEmployee(payload: {
@@ -142,6 +247,9 @@ export async function changeEmployeeData(payload: {
effective_date: string;
person: Record<string, unknown>;
contract: Record<string, unknown>;
// `role` trägt seit 20260917120000 auch `source`. Wechselt sie von
// „Extern" auf „Intern", schreibt change_employee_data zusätzlich das
// Ereignis „Übernahme" — der umgekehrte Weg erzeugt keines.
role: Record<string, unknown>;
}): Promise<ActionResult> {
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
@@ -159,8 +267,54 @@ export async function rehireEmployee(payload: {
employee_id: string;
rehire_date: string;
position_id: string;
/**
* Die Stammdaten, wie sie der Assistent zeigt — alle freiwillig.
*
* Fehlt ein Schlüssel, bleibt der bestehende Wert stehen. Das hält den
* schlanken Aufruf (nur Datum und Planstelle) am Leben und erlaubt dem
* Assistenten trotzdem, den ganzen Satz zu schicken, ohne dass danach eine
* zweite Transaktion nötig wäre — siehe Migration 20260917130000.
*/
[feld: string]: unknown;
}): Promise<ActionResult> {
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
try {
await withUser(await currentUserId(), async (tx) => {
await callFunction(tx, "rehire_employee", payload as unknown as Record<string, unknown>);
// Auch bei der Wiedereinstellung: Dienstzettel, Bankverbindung und
// E-Card sind wieder zu erledigen. Die Punkte von damals stehen noch da
// und bleiben stehen — start_onboarding legt nur an, was fehlt, statt
// einen alten Haken zu löschen. Was wirklich neu zu tun ist, entscheidet
// HR an der Liste; ein Programm an ihrer Stelle würde raten.
await callFunction(tx, "start_onboarding", {
employee_id: payload.employee_id,
item_keys: ONBOARDING_PUNKTE.map((x) => x.key),
});
});
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
return { success: true };
}
/** Einen Punkt der Checkliste festhalten — Haken, Wert oder Kommentar. */
export async function setOnboardingTask(payload: {
employee_id: string;
item_key: string;
erledigt?: boolean;
wert?: string | null;
kommentar?: string | null;
}): Promise<ActionResult> {
return callRpc("set_onboarding_task", payload, [`/employees/${payload.employee_id}`]);
}
/** Legt die Checkliste nachträglich an — für Personen von vor dieser Liste. */
export async function startOnboarding(employeeId: string): Promise<ActionResult> {
return callRpc(
"start_onboarding",
{ employee_id: employeeId, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) },
[`/employees/${employeeId}`]
);
}
export async function addEmployeeDependent(payload: {

View File

@@ -1,10 +1,74 @@
"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { currentUserId, requireUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import type { ActionResult } from "@/lib/db/rpc";
/**
* Den Entwurf für mich belegen — beim Öffnen und danach im Takt.
*
* Die Regel hire_drafts_update lässt die Zeile nur durch, wenn die Sperre
* frei, meine oder abgelaufen ist. Trifft das Schreiben keine Zeile, hat sie
* jemand anderes offen; das ist kein Fehler, sondern die Auskunft.
*
* Dieselbe Funktion frischt die Sperre auf: sie schreibt locked_at neu,
* solange sie mir gehört. Der Assistent ruft sie darum im Takt, sonst liefe
* die Frist einem langen Ausfüllen davon.
*
* updated_at bleibt unberührt. Eine Sperre ist keine Änderung am Entwurf —
* bewegte sie den Zeitstempel, sortierte sich die Liste um und „Gespeichert
* am" logge.
*/
export async function entwurfSperren(id: string): Promise<ActionResult> {
const userId = await requireUserId();
try {
const ergebnis = await withUser(userId, (tx) =>
tx
.updateTable("hire_drafts")
.set({ locked_by: userId, locked_at: new Date().toISOString() })
.where("id", "=", id)
.executeTakeFirst()
);
if (ergebnis.numUpdatedRows === 0n) {
return { success: false, error: "Dieser Entwurf wird gerade von jemand anderem bearbeitet." };
}
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
revalidatePath("/");
return { success: true };
}
/**
* Die Sperre zurückgeben.
*
* Die Bedingung auf locked_by steht hier und nicht nur in der Regel: die
* Regel liesse auch das Freigeben einer *abgelaufenen fremden* Sperre zu,
* und das wäre ein stiller Griff in die Arbeit von jemandem, der gerade
* wieder aufgefrischt hat.
*
* Ein Scheitern bleibt ohne Meldung: die Frist räumt die Sperre ohnehin ab,
* und es gäbe nichts, was die Person daraufhin tun könnte.
*/
export async function entwurfFreigeben(id: string): Promise<ActionResult> {
const userId = await requireUserId();
try {
await withUser(userId, (tx) =>
tx
.updateTable("hire_drafts")
.set({ locked_by: null, locked_at: null })
.where("id", "=", id)
.where("locked_by", "=", userId)
.execute()
);
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
revalidatePath("/");
return { success: true };
}
export async function saveHireDraft(payload: {
id?: string;
step: number;
@@ -16,13 +80,25 @@ export async function saveHireDraft(payload: {
try {
const id = await withUser(userId, async (tx) => {
if (payload.id) {
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
// Policy hire_drafts_owner — nicht eine Prüfung hier.
await tx
// Wer schreiben darf, entscheidet die Regel hire_drafts_update:
// der Entwurf muss meiner oder von einer hinzugewählten Person sein,
// **und** die Sperre muss mir gehören.
//
// Die Zahl der geänderten Zeilen wird trotzdem gelesen: eine Regel
// weist ein UPDATE nicht mit einem Fehler ab, sie lässt es ins Leere
// laufen. Ohne diese Prüfung meldete die Anwendung „gespeichert", und
// gespeichert wäre nichts — der ärgerlichste Fall überhaupt, weil die
// Person den Assistenten daraufhin beruhigt zumacht.
const ergebnis = await tx
.updateTable("hire_drafts")
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
.where("id", "=", payload.id)
.execute();
.executeTakeFirst();
if (ergebnis.numUpdatedRows === 0n) {
throw new Error(
"Der Entwurf liess sich nicht speichern: er wird gerade von jemand anderem bearbeitet oder wurde inzwischen gelöscht."
);
}
return payload.id;
}
@@ -43,7 +119,18 @@ export async function saveHireDraft(payload: {
export async function deleteHireDraft(id: string): Promise<ActionResult> {
try {
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
// Auch hier die Zeilenzahl: die Regel hire_drafts_delete lässt ein
// Löschen gegen eine fremde Sperre leer laufen statt es abzuweisen.
// „Entwurf gelöscht" über einem Entwurf, der noch dasteht, wäre die
// schlechtere Auskunft.
await withUser(await currentUserId(), async (tx) => {
const ergebnis = await tx.deleteFrom("hire_drafts").where("id", "=", id).executeTakeFirst();
if (ergebnis.numDeletedRows === 0n) {
throw new Error(
"Der Entwurf liess sich nicht löschen: er wird gerade von jemand anderem bearbeitet oder war schon weg."
);
}
});
revalidatePath("/");
return { success: true };
} catch (err) {

66
actions/notes.ts Normal file
View File

@@ -0,0 +1,66 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import type { ActionResult } from "@/lib/db/rpc";
/**
* Eine Kollegin oder einen Kollegen hinzuwählen oder abwählen.
*
* Die Auswahl steuert zwei Listen: die Notizen in der Glocke und die
* Entwürfe auf der Übersicht. Der Name der Funktion nennt nur die erste,
* weil die Einstellung in der Glocke sitzt — was sie bewirkt, steht an der
* Einstellung selbst.
*
* Kein Aufruf einer SQL-Funktion und kein Protokolleintrag, anders als bei
* allem, was Personaldaten ändert: das hier ist eine persönliche
* Anzeigeeinstellung. Ein Prüfprotokoll, das jeden Haken mitschreibt, machte
* die Suche nach echten Änderungen mühsamer, ohne etwas nachzuweisen.
* Dasselbe Muster wie bei gespeicherten Auswertungen und Entwürfen
* (actions/reports.ts, actions/hireDrafts.ts).
*
* Abgesichert ist es trotzdem: die Regel `colleague_subscriptions_owner` lässt nur Zeilen
* zu, deren `user_id` die angemeldete Person ist. Eine fremde Einstellung
* liesse sich auch mit erfundenen Werten nicht schreiben.
*/
export async function setNotizSichtbarkeit(payload: {
kollegeId: string;
sichtbar: boolean;
}): Promise<ActionResult> {
const userId = await requireUserId();
// Die eigenen Notizen sind ohnehin immer dabei. Die Prüfbedingung der
// Tabelle weist das ab; hier kommt die Meldung heraus, die jemand lesen
// kann, statt einer Verletzungsmeldung aus der Datenbank.
if (payload.kollegeId === userId) {
return { success: false, error: "Die eigenen Notizen sind immer dabei." };
}
try {
await withUser(userId, async (tx) => {
if (payload.sichtbar) {
// `on conflict do nothing`: zweimal dasselbe Hinzuwählen ist kein
// Fehler, sondern derselbe Wunsch — etwa wenn zwei Reiter offen sind.
await tx
.insertInto("colleague_subscriptions")
.values({ user_id: userId, author_user_id: payload.kollegeId })
.onConflict((oc) => oc.columns(["user_id", "author_user_id"]).doNothing())
.execute();
} else {
await tx
.deleteFrom("colleague_subscriptions")
.where("user_id", "=", userId)
.where("author_user_id", "=", payload.kollegeId)
.execute();
}
});
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
// Die Glocke steckt in der Hülle jeder Seite, die Karte „Anstehend" auf der
// Übersicht. Beide zeigen dieselbe Menge und müssen gemeinsam nachziehen.
revalidatePath("/", "layout");
return { success: true };
}

61
actions/passwort.ts Normal file
View File

@@ -0,0 +1,61 @@
"use server";
import { redirect } from "next/navigation";
import { currentUserId } from "@/lib/auth/session";
import { runMutation } from "@/lib/db/rpc";
// Das eigene Passwort wechseln.
//
// Getrennt von actions/auth.ts, weil es etwas anderes ist: dort geht es um das
// Herstellen einer Sitzung, hier um eine Änderung an den eigenen Daten. Und
// getrennt von actions/benutzer.ts (Benutzerverwaltung), weil diese Aktion die
// einzige ist, die **ohne** HR-Rechte funktionieren muss.
export type WechselZustand = { fehler: string | null };
/**
* Wechselt das Passwort der angemeldeten Person.
*
* Kein `revalidatePath`: der Weg danach ist eine Weiterleitung durch die
* Komponente, und es gibt keine zwischengespeicherte Seite, die den alten Stand
* zeigen könnte.
*
* Die Meldungen kommen aus app_passwort_aendern() und sind für die Oberfläche
* geschrieben („Das bisherige Passwort stimmt nicht.") — sie werden
* durchgereicht wie bei jeder anderen Mutation.
*/
export async function passwortAendern(
_zustand: WechselZustand,
formular: FormData
): Promise<WechselZustand> {
const altes = String(formular.get("altes_passwort") ?? "");
const neues = String(formular.get("neues_passwort") ?? "");
const wiederholung = String(formular.get("wiederholung") ?? "");
if (!altes || !neues) {
return { fehler: "Bitte das bisherige und das neue Passwort eingeben." };
}
// Diese eine Prüfung gehört hierher und nicht in die Datenbank: die
// Wiederholung ist ein Bedienelement gegen Vertippen, kein Datum. Die
// Datenbank sieht sie nie und soll sie auch nicht sehen.
if (neues !== wiederholung) {
return { fehler: "Die beiden neuen Passwörter stimmen nicht überein." };
}
const ergebnis = await runMutation(await currentUserId(), "app_passwort_aendern", {
altes_passwort: altes,
neues_passwort: neues,
});
if (!ergebnis.success) {
return { fehler: ergebnis.error ?? "Unbekannter Fehler." };
}
// Ab hier ist der Wechsel erledigt, is_hr_user() liefert wieder true und die
// Anwendung ist offen. Die Weiterleitung steht in der Aktion und nicht in der
// Komponente: `redirect()` wirft, die Aktion kehrt also nicht zurück, und es
// gibt keinen Zwischenzustand, in dem das Formular noch einmal abgeschickt
// werden könnte.
redirect("/");
}

View File

@@ -7,7 +7,7 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc";
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
fn: "create_position" | "clone_position" | "update_position" | "delete_position" | "set_position_cost_center",
payload: Record<string, unknown>,
revalidate: string[]
): Promise<ActionResult> {
@@ -26,6 +26,23 @@ export async function createPosition(payload: {
return callRpc("create_position", payload, POSITION_PATHS);
}
/**
* Eine Planstelle als Kopie einer bestehenden anlegen.
*
* Kein eigenes Formular: Einheit, Tätigkeit und Kontierung kommen von der
* Vorlage, getippt wird nichts. Genau das ist der Zweck — zwölf gleiche
* Planstellen von Hand anzulegen erzeugt zwölf Gelegenheiten, die Tätigkeit
* unterschiedlich zu schreiben, und ab der zweiten Schreibweise ist jede
* Auswertung nach Tätigkeit falsch.
*
* Dass Leitungsplanstellen ausgenommen sind, entscheidet die SQL-Funktion und
* nicht diese Stelle. Die Oberfläche blendet den Knopf dort aus, aber das ist
* eine Bequemlichkeit, keine Regel.
*/
export async function clonePosition(payload: { position_id: string; valid_from?: string }): Promise<ActionResult> {
return callRpc("clone_position", payload, POSITION_PATHS);
}
/**
* Ändert eine Planstelle.
*

View File

@@ -21,8 +21,8 @@ function pageHref(params: SearchParams, page: number): string {
}
// Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
// an unpinned formatter renders it in the *server's* zone — UTC in Docker and
// on Vercel — so every entry would read an hour or two early for the people
// an unpinned formatter renders it in the *server's* zone — UTC in the
// container — so every entry would read an hour or two early for the people
// the log is for.
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
day: "2-digit",
@@ -80,7 +80,13 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
</Suspense>
<p className="text-sm text-ink-muted">{count} Einträge</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}>
{/* `overflow-y-hidden` gehört zwingend dazu und ist keine Vorsicht:
nach CSS wird eine auf `visible` stehende Achse auf `auto`
hochgestuft, sobald die andere nicht `visible` ist. `overflow-x-auto`
allein macht aus der Karte also einen Scrollbereich in **beiden**
Richtungen — mit einer senkrechten Leiste, die niemand bestellt hat.
Geklemmt wird dabei nichts: die Karte ist so hoch wie ihr Inhalt. */}
<div className={`overflow-x-auto overflow-y-hidden ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm">
<thead>
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">

View File

@@ -0,0 +1,54 @@
import { notFound } from "next/navigation";
import { PrintChecklist } from "@/components/employees/PrintChecklist";
import { currentUserId } from "@/lib/auth/session";
import { artTitel, checklistDateiname, parseArt } from "@/lib/checklist-print";
import { loadChecklistPrint } from "@/lib/checklist-print-data";
import { withUser } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding";
import { ONBOARDING_GRUPPEN } from "@/lib/onboarding";
// Eigene Route statt eines Druckstils auf dem Reiter.
//
// Der Reiter ist zum Arbeiten gebaut: Haken zum Anklicken, Eingabefelder,
// Kommentarknöpfe. Auf Papier ist davon nichts nützlich. Hier entsteht aus
// denselben Daten ein Blatt, das den Stand festhält — mit Kopf, Fortschritt
// und Platz für zwei Unterschriften.
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<{ art?: string }>;
};
/**
* Der Titel ist zugleich der Dateivorschlag im Druckdialog. Er wird hier
* gesetzt *und* in der Komponente noch einmal — hier für den Tab und den Fall,
* dass jemand die Seite direkt druckt, dort, weil der Titel aus den geladenen
* Daten kommt und die Metadaten sonst ein zweites Mal laden müssten.
*/
export async function generateMetadata({ params, searchParams }: Props) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const art = parseArt(sp.art);
const daten = await withUser(await currentUserId(), (tx) => loadChecklistPrint(tx, id, art));
if (!daten) return { title: artTitel(art) };
return { title: checklistDateiname(art, daten.kopf, todayIso()) };
}
export default async function ChecklistPrintPage({ params, searchParams }: Props) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
const art = parseArt(sp.art);
const today = todayIso();
const daten = await withUser(await currentUserId(), (tx) => loadChecklistPrint(tx, id, art, today));
if (!daten) notFound();
return (
<PrintChecklist
art={art}
kopf={daten.kopf}
gruppen={art === "offboarding" ? OFFBOARDING_GRUPPEN : ONBOARDING_GRUPPEN}
staende={daten.staende}
today={today}
/>
);
}

View File

@@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
if (!data) notFound();
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data;
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, offboarding, openPositions, byId } = data;
return (
<EmployeeDetail
@@ -32,6 +32,8 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
}
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
kostenstelle={kostenstelle}
onboarding={onboarding}
offboarding={offboarding}
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
// zweimal da.
@@ -49,6 +51,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
notes={notes ?? []}
locations={orgMaps.locationList}
openPositions={openPositions}
today={today}
/>
);
}

View File

@@ -9,24 +9,70 @@ import { currentUserId } from "@/lib/auth/session";
import { sql, withUser } from "@/lib/db";
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
import {
SORTIERFELDER,
naechsteRichtung,
sortiere,
parseFeld,
parseRichtung,
type Richtung,
type Sortierfeld,
} from "@/lib/employee-sort";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, fmtName, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import type { EmploymentStatus } from "@/lib/supabase/types";
import type { EmploymentStatus } from "@/lib/types";
const PAGE_SIZE = 15;
/**
* Die Parameter, nachdem sie geglättet sind — je einer, oder keiner.
*
* Was Next tatsächlich liefert, steht in RohParams: derselbe Name zweimal in
* der Adresse wird dort zu einem Array. Diese Seite hat das nicht erwartet
* und ist daran gescheitert — eine Kachel der Übersicht verwies auf
* `?status=Aktiv&status=Karenz`, und `params.status.split(",")` lief gegen
* ein Array. Sichtbar war davon nur „Diese Ansicht konnte nicht geladen
* werden" mit einer Fehlerkennung.
*
* Die Kachel ist berichtigt (sie schreibt jetzt `status=Aktiv,Karenz`), aber
* eine Adresse kommt nicht nur aus der eigenen Anwendung: sie steht in
* Lesezeichen, in Verknüpfungen, in E-Mails. Deshalb wird hier geglättet
* statt sich darauf zu verlassen, dass niemand zweimal denselben Namen
* schreibt.
*/
type SearchParams = {
q?: string;
division?: string;
status?: string;
location?: string;
sort?: string;
dir?: string;
page?: string;
};
type RohParams = Record<string, string | string[] | undefined>;
/** Der erste Wert eines Parameters — auch wenn er mehrfach in der Adresse steht. */
function einWert(wert: string | string[] | undefined): string | undefined {
return Array.isArray(wert) ? wert[0] : wert;
}
function glaetten(roh: RohParams): SearchParams {
return {
q: einWert(roh.q),
division: einWert(roh.division),
status: einWert(roh.status),
location: einWert(roh.location),
sort: einWert(roh.sort),
dir: einWert(roh.dir),
page: einWert(roh.page),
};
}
type EmployeesPageProps = {
searchParams: Promise<SearchParams>;
searchParams: Promise<RohParams>;
};
function pageHref(params: SearchParams, page: number): string {
@@ -35,13 +81,36 @@ function pageHref(params: SearchParams, page: number): string {
if (params.division) sp.set("division", params.division);
if (params.status) sp.set("status", params.status);
if (params.location) sp.set("location", params.location);
// Ohne das kippte die Liste beim Blättern zurück auf den Standard, und
// Seite 2 zeigte Namen, die auf Seite 1 schon standen.
if (params.sort) sp.set("sort", params.sort);
if (params.dir) sp.set("dir", params.dir);
sp.set("page", String(page));
return `/employees?${sp.toString()}`;
}
/**
* Die Adresse hinter einem Spaltenkopf.
*
* Ohne `page`: nach dem Umsortieren steht auf Seite 7 etwas völlig anderes
* als vorher. Wer sortiert, will von vorne anfangen.
*/
function sortHref(params: SearchParams, feld: Sortierfeld, aktuell: Sortierfeld, richtung: Richtung): string {
const sp = new URLSearchParams();
if (params.q) sp.set("q", params.q);
if (params.division) sp.set("division", params.division);
if (params.status) sp.set("status", params.status);
if (params.location) sp.set("location", params.location);
sp.set("sort", feld);
sp.set("dir", naechsteRichtung(aktuell, richtung, feld));
return `/employees?${sp.toString()}`;
}
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
const params = await searchParams;
const params = glaetten(await searchParams);
const page = Math.max(1, Number(params.page ?? "1") || 1);
const sortFeld = parseFeld(params.sort);
const sortRichtung = parseRichtung(params.dir);
const today = todayIso();
// Comma-separated, so a dashboard tile can link here with the same
@@ -171,14 +240,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
"entry_date",
"employment_type",
"weekly_hours",
"status",
// Nicht `status`: der Chip leitet ab, wie der Filter es tut —
// siehe components/ui/StatusChip.tsx. Drei Spalten mehr auf
// fünfzehn Zeilen, keine zusätzliche Rundreise.
"exit_date",
"karenz_start_date",
"karenz_return_date",
"absence_type",
])
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
// Seiten erscheinen oder auf keiner.
.orderBy("last_name")
.orderBy("id")
.$call((q) => sortiere(q, sortFeld, sortRichtung, today))
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
).as("rows"),
@@ -217,17 +287,49 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
)}
</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}>
{/* Zur zweiten Achse siehe den Kommentar auf der Protokollseite: ohne
`overflow-y-hidden` stuft CSS sie auf `auto` hoch, und die Karte
bekommt eine senkrechte Leiste, die nichts bewirkt. */}
<div className={`overflow-x-auto overflow-y-hidden ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm">
<thead>
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="px-4 py-2.5">Mitarbeiter:in</th>
<th className="px-4 py-2.5">Pers.-Nr.</th>
<th className="px-4 py-2.5">Bereich/Team</th>
<th className="px-4 py-2.5">Standort</th>
<th className="px-4 py-2.5">Eintritt</th>
<th className="px-4 py-2.5">Beschäftigung</th>
<th className="px-4 py-2.5">Status</th>
{SORTIERFELDER.map((f) => {
const aktiv = f.value === sortFeld;
return (
// `aria-sort` sagt einem Screenreader, welche Spalte die
// Reihenfolge bestimmt und in welche Richtung — der Pfeil
// allein ist für ihn nicht da.
<th
key={f.value}
scope="col"
aria-sort={aktiv ? (sortRichtung === "asc" ? "ascending" : "descending") : "none"}
className="px-4 py-2.5"
>
<Link
href={sortHref(params, f.value, sortFeld, sortRichtung)}
// `title` nennt aus, was der Pfeil andeutet: was der
// nächste Klick tut, nicht was gerade gilt.
title={
aktiv && sortRichtung === "asc"
? `${f.label} absteigend sortieren`
: `${f.label} aufsteigend sortieren`
}
className={`group inline-flex items-center gap-1 rounded transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
aktiv ? "text-ink" : ""
}`}
>
{f.label}
{/* Der Pfeil steht immer da, nur blass, solange die
Spalte nicht sortiert: sonst springt die Kopfzeile
beim Klicken um eine Pfeilbreite. */}
<span aria-hidden className={aktiv ? "text-brand-600" : "text-ink-muted/30 group-hover:text-ink-muted"}>
{aktiv && sortRichtung === "desc" ? "▼" : "▲"}
</span>
</Link>
</th>
);
})}
</tr>
</thead>
<tbody>
@@ -272,7 +374,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
</td>
<td className="px-4 py-2.5">
<StatusChip status={e.status} entryDate={e.entry_date} absenceType={e.absence_type} />
<StatusChip employee={e} asOf={today} />
</td>
</tr>
);

View File

@@ -8,7 +8,7 @@ import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
// Without this file a failed render drops the user on Next.js's own error
// screen — no navigation, no way back, and in production just "a client-side
// exception occurred". `reset()` re-renders the segment, which is enough for
// the common case of a transient Supabase timeout.
// the common case of a transient database timeout.
export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error("Route error:", error);

View File

@@ -13,21 +13,30 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
const data = await withUser(userId, (tx) => loadShellData(tx, userId));
const ergebnis = await withUser(userId, (tx) => loadShellData(tx, userId));
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
// Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat keine
// Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
// wortlosen Anmeldeseite und versuchte es endlos erneut.
if (!data) redirect("/login?error=no_hr_access");
// Sitzungstoken.
//
// Beide Umleitungen sind Bedienkomfort, keine Absicherung: wer sie umgeht,
// bekommt trotzdem keine Zeile, weil die RLS-Policies dieselbe Frage stellen
// (is_hr_user()). Ohne sie stünde die Person nur vor einer leeren Anwendung
// und wüsste nicht, warum.
if (ergebnis.status === "passwort_wechseln") redirect("/passwort-aendern");
// Ohne den Grund in der Adresse stünde die Person vor einer wortlosen
// Anmeldeseite und versuchte es endlos erneut.
if (ergebnis.status === "kein_zugang") redirect("/login?error=no_hr_access");
const data = ergebnis.daten;
const userLabel = data.profile.full_name || data.profile.email || "";
return (
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
<AppShell userLabel={userLabel} openNotes={data.openNotes} kollegen={data.kollegen}>
{children}
</AppShell>
</HireWizardProvider>

View File

@@ -1,5 +1,5 @@
// Every page in this group is server-rendered per request (they all read
// from Supabase), so without this the browser sits on the previous page with
// from the database), so without this the browser sits on the previous page with
// no feedback until the server answers — on the employee list, long enough
// to look broken.
export default function Loading() {

View File

@@ -1,13 +1,14 @@
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { Fragment, Suspense } from "react";
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
import { AnstehendListe, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe";
import { DraftsCard } from "@/components/dashboard/DraftsCard";
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
import { actionBadgeStyle } from "@/lib/colors";
import { eventBadgeStyle, eventDotStyle } from "@/lib/colors";
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
import { loadDashboardData } from "@/lib/dashboard-data";
import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format";
import { addDaysIso, fmtName, todayIso } from "@/lib/format";
import { divisionOf } from "@/lib/org";
import { deriveStatusAsOf } from "@/lib/reports";
import { currentUserId } from "@/lib/auth/session";
@@ -22,28 +23,40 @@ const TONE: Record<string, { text: string; bar: string }> = {
danger: { text: "text-danger-text", bar: "bg-danger-text" },
warning: { text: "text-warning-text", bar: "bg-warning-text" },
brand: { text: "text-brand-700", bar: "bg-brand-500" },
// Geplante Eintritte: eine Ankündigung, kein Befund. „info" ist der Ton,
// den die Statuschips für „Geplant" ohnehin tragen.
info: { text: "text-info-text", bar: "bg-info-text" },
};
const DOT_STYLES: Record<string, string> = {
Eintritt: "bg-success-text",
Wiedereintritt: "bg-success-text",
Rückkehr: "bg-success-text",
Austritt: "bg-danger-text",
Versetzung: "bg-info-text",
Beförderung: "bg-purple-text",
Reorganisation: "bg-purple-text",
Karenz: "bg-warning-text",
Vertragsänderung: "bg-warning-text",
Stammdatenänderung: "bg-warning-text",
Gehaltsanpassung: "bg-warning-text",
/** Eine Kennzahl auf der Übersicht. */
type Kachel = { label: string; value: string | number; tone: keyof typeof TONE; href: string };
/**
* Wie viele Spalten die Überschrift einer Gruppe überspannt — nach der Zahl
* ihrer Kacheln.
*
* Ausgeschrieben und nicht als `span var(--kacheln)`: Tailwind erzeugt nur
* Klassen, die als Zeichenkette im Quelltext stehen, und eine aus einer
* Variablen berechnete Spannweite wäre zur Bauzeit nicht zu sehen. Die
* Tabelle deckt jede mögliche Gruppengrösse bis zur Rasterbreite ab; ein
* Zugriff daneben gäbe `undefined` und damit eine Überschrift über einer
* einzigen Spalte — auffällig genug, um bemerkt zu werden.
*/
const SPALTEN_SPAN: Record<number, string> = {
1: "xl:col-span-1",
2: "xl:col-span-2",
3: "xl:col-span-3",
4: "xl:col-span-4",
5: "xl:col-span-5",
6: "xl:col-span-6",
7: "xl:col-span-7",
8: "xl:col-span-8",
};
const KIND_LABEL = {
hire: "Eintritt",
exit: "Austritt",
return: "Rückkehr aus Abwesenheit",
note: "Wiedervorlage",
} as const;
// Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und
// Chip derselben Zeile kamen aus zwei getrennten Verzeichnissen, und eines
// davon war das falsche. Jetzt leiten beide aus EVENT_CATEGORY ab und koennen
// nicht mehr auseinanderlaufen.
export default async function DashboardPage({
searchParams,
@@ -121,17 +134,7 @@ export default async function DashboardPage({
.sort((a, b) => b.count - a.count);
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
type UpcomingItem = {
id: string;
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
employeeId: string;
label: string;
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
hinweis?: string;
date: string;
kind: keyof typeof KIND_LABEL;
};
const upcomingAlle: UpcomingItem[] = [
const upcomingAlle: AnstehendEintrag[] = [
...(upcomingHires).map((e) => ({
id: e.id,
employeeId: e.id,
@@ -163,12 +166,6 @@ export default async function DashboardPage({
})),
].sort((a, b) => a.date.localeCompare(b.date));
// Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter
// steht, wie viele es insgesamt sind. Wer alle sehen will, filtert enger
// oder geht in die Mitarbeiterliste.
const upcoming = upcomingAlle.slice(0, 8);
const weitere = upcomingAlle.length - upcoming.length;
// Each tile links to the view that shows what it counts, with the filters
// pre-applied.
//
@@ -179,14 +176,53 @@ export default async function DashboardPage({
// and rehire_employee sets entry_date but logs the event as
// 'Wiedereintritt'. A year with rehires therefore shows a slightly higher
// number on the tile than in the linked report.
const kpis = [
// ── Warum die Kacheln in Gruppen stehen ────────────────────────────
//
// Acht Zahlen nebeneinander sind acht Zahlen. Sie beantworten aber drei
// verschiedene Fragen: wie viele sind da, was hat sich bewegt, was ist
// offen. Ohne Überschrift muss man jede Beschriftung einzeln lesen, um das
// herauszufinden — mit ihr sieht man es, bevor man liest.
//
// Die Einteilung und die Reihenfolge kommen aus einem Entwurf des Kunden
// (E-Mail vom 16.09.2026). „Aktives Dienstverhältnis" steht darin bewusst
// **vorn**: es ist die Bezugsgrösse fast jeder Personalkennzahl, und wer
// eine Quote bildet, greift zuerst danach.
const kpiGruppen: { titel: string; kacheln: Kachel[] }[] = [
{
label: "Aktive Mitarbeiter:innen",
value: activeCount,
titel: "Personalstand",
kacheln: [
// Nicht dasselbe wie „Aktive Mitarbeiter:innen" daneben: dort steht,
// wer heute arbeitet, hier, mit wem ein Vertrag läuft —
// Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10,
// addieren musste man selbst.
{
label: "Aktives Dienstverhältnis",
value: activeCount + karenzCount,
tone: "default",
href: "/employees?status=Aktiv",
// Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter
// als *eine* Zeichenkette und trennt selbst. Zweimal übergeben macht
// Next daraus ein Array, und die Seite scheiterte an `.split(",")`.
href: "/employees?status=Aktiv,Karenz",
},
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
// „(HC)" für Headcount — der Unterschied zur Kachel links ist genau
// der, den die Abkürzung benennt: Köpfe, die heute arbeiten.
{ label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
// Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht.
{
label: "Geplante Eintritte",
value: staffRows.filter((row) => statusOf(row) === "Geplant").length,
tone: "info",
href: "/employees?status=Geplant",
},
{ label: "Aktive FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
],
},
{
titel: "Personalbewegung",
kacheln: [
{
label: "Eintritte (Jahr)",
value: hiresYtd,
@@ -199,30 +235,78 @@ export default async function DashboardPage({
tone: "danger",
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
},
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
],
},
{
titel: "Recruiting & Vakanzen",
kacheln: [{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }],
},
];
return (
<div className="flex flex-col gap-6">
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{kpis.map((kpi) => (
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
{/* ── Ein Raster, nicht drei nebeneinandergestellte ──────────────
Alle acht Kacheln liegen in **einem** Raster mit acht gleichen
Spalten und einem einzigen Abstandswert. Nur so sind sie gleich
breit und gleich weit auseinander — unabhängig davon, zu welcher
Gruppe sie gehören.
Die beiden Anläufe davor stellten drei eigene Raster nebeneinander.
Das ging zweimal schief: erst bekam jede Gruppe ein Drittel der
Breite (fünf Kacheln gequetscht, eine mit demselben Platz), dann
einen Anteil nach Kachelzahl — da stimmten die Breiten fast, aber
eben nur fast: eine Gruppe mit fünf Kacheln hat vier Abstände in
sich, eine mit einer keinen, und dieser Unterschied verteilt sich
auf die Kachelbreiten. Gleichmässig wird es erst, wenn alle Kacheln
demselben Raster angehören.
Die Überschriften sitzen in Zeile 1 und überspannen die Spalten
ihrer Gruppe, die Kacheln in Zeile 2. Beide Zeilen entstehen aus
derselben DOM-Reihenfolge (Überschrift, ihre Kacheln, nächste
Überschrift …) — die ist für Vorlesegeräte die richtige, und die
ausdrückliche Zeilenangabe sortiert sie fürs Auge.
Unterhalb der grossen Breite gibt es keine Zeilenangabe: dann fliesst
alles der Reihe nach, die Überschrift über die volle Breite und ihre
Kacheln zu zweit darunter. Acht nebeneinander wären auf einem Laptop
unlesbar schmal. */}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 xl:grid-cols-8 xl:gap-y-1.5">
{kpiGruppen.map((gruppe) => (
<Fragment key={gruppe.titel}>
<h2
className={`mt-2 self-end text-[11px] font-bold uppercase tracking-wider text-ink-muted
col-span-2 xl:mt-0 xl:[grid-row:1] ${SPALTEN_SPAN[gruppe.kacheln.length]}`}
>
{gruppe.titel}
</h2>
{gruppe.kacheln.map((kpi) => (
<Link
key={kpi.label}
href={kpi.href}
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow xl:[grid-row:2] hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
>
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
{/* Number first in the visual order: it is what the eye is
looking for, and the label only qualifies it. */}
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
looking for, and the label only qualifies it.
Kleiner, sobald alle acht in einer Reihe stehen: dort
bleiben je Kachel gut 110 px, und „748.4" braucht in 30 px
Schriftgrad mehr davon, als nach dem Innenabstand übrig
ist. Die Kachel hat `overflow-hidden` — die Zahl wäre also
nicht zu breit, sondern abgeschnitten, und eine
abgeschnittene Kennzahl ist schlimmer als eine kleine. */}
<div className={`text-3xl font-extrabold leading-none tabular-nums xl:text-2xl ${TONE[kpi.tone].text}`}>
{kpi.value}
</div>
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
{kpi.label}
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
</div>
</Link>
))}
</Fragment>
))}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
@@ -258,49 +342,15 @@ export default async function DashboardPage({
<Suspense fallback={<div className="mb-2 h-9" />}>
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
</Suspense>
<ul className="flex flex-col divide-y divide-border-subtle">
{upcoming.map((item) => {
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
// haben eine untere Grenze, eine offene Aufgabe nicht.
const ueberfaellig = item.date < today;
return (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/employees/${item.employeeId}`}
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
>
<span className="min-w-0">
<span className="block truncate font-semibold text-ink">{item.label}</span>
<span className="block truncate text-xs text-ink-muted">
{KIND_LABEL[item.kind]}
{item.hinweis ? ` — ${item.hinweis}` : ""}
</span>
</span>
<span
className={`shrink-0 text-xs font-semibold tabular-nums ${
ueberfaellig ? "text-danger-text" : "text-ink-muted"
}`}
>
{ueberfaellig ? "überfällig " : ""}
{fmtDate(item.date)}
</span>
</Link>
</li>
);
})}
{upcoming.length === 0 && (
<p className="py-2 text-sm text-ink-muted">
{istEingeschraenkt(zeitraum, arten)
<AnstehendListe
eintraege={upcomingAlle}
today={today}
leerText={
istEingeschraenkt(zeitraum, arten)
? "Zu dieser Auswahl steht nichts an."
: "Keine anstehenden Ereignisse."}
</p>
)}
</ul>
{weitere > 0 && (
<p className="mt-2 text-xs text-ink-muted">
… und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum.
</p>
)}
: "Keine anstehenden Ereignisse."
}
/>
</Card>
<Card>
@@ -310,11 +360,11 @@ export default async function DashboardPage({
<li key={h.id} className="flex gap-2.5 py-2.5">
{/* Dot aligned to the first line of text, not centred on the
whole row, so it stays put as descriptions wrap. */}
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${eventDotStyle(h.event_type)}`} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${eventBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>
</div>

View File

@@ -6,6 +6,7 @@ import {
parseEventDateParam,
parseEventGroupDimension,
parseEventSplitDimension,
parseEventSubtype,
parseEventType,
parseGroupDimension,
parseIsoDateParam,
@@ -33,6 +34,7 @@ type SearchParams = Record<string, string | string[] | undefined> & {
status?: string;
asOf?: string;
eventType?: string;
eventSubtype?: string;
from?: string;
to?: string;
};
@@ -48,6 +50,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
const eventGroup = parseEventGroupDimension(params.group);
const eventSplit = parseEventSplitDimension(params.split);
const eventType = parseEventType(params.eventType);
const eventSubtype = parseEventSubtype(params.eventSubtype, eventType);
const from = parseEventDateParam(params.from);
const to = parseEventDateParam(params.to);
const measure = parseMeasure(params.measure);
@@ -71,6 +74,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
mode === "events"
? loadEventHistory(tx, {
eventType: eventType ?? undefined,
eventSubtype: eventSubtype ?? undefined,
division: params.division,
location: params.location,
from,
@@ -108,6 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
eventGroup={eventGroup}
eventSplit={eventSplit ?? ""}
eventType={eventType ?? ""}
eventSubtype={eventSubtype ?? ""}
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
rows={rows}
total={sumValues(rows)}

View File

@@ -1,4 +1,6 @@
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
import { PasswortFormular } from "@/components/auth/PasswortFormular";
import { AppWortmarke } from "@/components/brand/Logo";
import { logout, signInWithEntra } from "@/actions/auth";
// The query string is attacker-controlled, so the login page renders a message
@@ -15,6 +17,15 @@ const ERROR_MESSAGES = {
title: "Anmeldung fehlgeschlagen",
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
},
// Auth.js' eigener Code für eine gescheiterte Passwortanmeldung. Im Regelfall
// kommt er hier nicht an — die Server Action fängt den Fehlschlag ab und
// zeigt ihn am Formular. Ohne diesen Eintrag fiele er aber auf `sso_failed`
// zurück, und dort stünde „über das Firmenkonto", was mit dem tatsächlichen
// Weg nichts zu tun hätte.
CredentialsSignin: {
title: "Anmeldung fehlgeschlagen",
body: "E-Mail-Adresse oder Passwort stimmt nicht.",
},
} as const;
type ErrorCode = keyof typeof ERROR_MESSAGES;
@@ -46,8 +57,11 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
<div className="w-full max-w-sm">
{/* Ohne Versatz: hier steht das Logo auf Weiss und bekommt nach §4.1
sein rosa Feld, dessen Kante mit der Ueberschrift darunter
fluchten soll. */}
<div className="lg:hidden">
<Wordmark className="text-ink" />
<AppWortmarke hoehe={26} textKlasse="text-ink" />
</div>
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
@@ -77,6 +91,20 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
<EntraSignInButton />
</form>
{/* Der Passwortweg steht bewusst *unter* dem Firmenkonto und ohne
eigene Überschrift: er ist die Ausnahme für die Testphase, nicht
die zweite gleichwertige Möglichkeit. Wer ein Firmenkonto hat,
soll oben klicken. */}
<div className="mt-6 flex items-center gap-3" aria-hidden>
<span className="h-px flex-1 bg-border-subtle" />
<span className="text-xs font-semibold uppercase tracking-wider text-ink-muted">oder</span>
<span className="h-px flex-1 bg-border-subtle" />
</div>
<div className="mt-6">
<PasswortFormular />
</div>
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
Personaldaten verschlossen.
@@ -89,55 +117,59 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
function BrandPanel() {
return (
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
ohne von der einen Schaltfläche gegenüber abzulenken. */}
// Rosa und nicht Blau: Rosa ist die Farbe, in der die Marke auftritt, und
// die Flaeche ist das Groesste, was diese Seite zu vergeben hat. Blau war
// hier doppelt falsch — es drehte das Verhaeltnis der beiden Markenfarben
// um, und das Logo bringt sein eigenes rosa Feld mit (§1.2), das auf Blau
// wie ein aufgeklebtes Rechteck stand. Auf Rosa geht es in die Flaeche
// ueber, und genau das meint §3.1 mit „in einem grossflaechigen rosa
// Umfeld eingebettet".
<aside className="relative hidden overflow-hidden bg-accent-500 lg:flex lg:flex-col lg:justify-between lg:p-12">
{/* Ein feines Raster und eine leichte Tiefe zur unteren Ecke — genug
Struktur, damit die Fläche nicht wie ein Farbfehler wirkt, aber ohne
Bilddatei und ohne von der einen Schaltfläche gegenüber abzulenken.
Beides aus ink: Weiss verschwindet auf Rosa, und die Lichtblende, die
hier vorher lag, hellte die Markenfarbe so weit auf, dass sie kaum
noch die Markenfarbe war. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
"radial-gradient(45rem 45rem at 100% 100%, rgb(35 31 32 / 0.08), transparent 60%)," +
"linear-gradient(rgb(35 31 32 / 0.04) 1px, transparent 1px)," +
"linear-gradient(90deg, rgb(35 31 32 / 0.04) 1px, transparent 1px)",
backgroundSize: "auto, 3rem 3rem, 3rem 3rem",
}}
/>
<div className="relative">
<Wordmark className="text-white" />
{/* -ml-3 gleicht den Freiraum des Logos aus (hoehe 36 / 3 = 12 px),
damit der Schriftzug buendig mit dem Text darunter beginnt. */}
{/* Alles Geschriebene hier steht in brand-700. Weiss faellt auf Manner
Rosa mit 2.18:1 aus, und ink traegt zwar (7.47:1), sieht aber aus wie
Text, der aus Versehen auf der Marke gelandet ist. Dunkelblau auf
Rosa (6.60:1) ist dieselbe Paarung, aus der der Schriftzug selbst
besteht. Die Abstufung macht Groesse und Gewicht, nicht Transparenz:
eine aufgehellte Schrift waere genau das, was hier durchfaellt. */}
<div className="relative -ml-3">
<AppWortmarke hoehe={36} aufRosa textKlasse="text-brand-700" />
</div>
<div className="relative max-w-md">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
Die Organisation, so wie sie heute wirklich aussieht.
</p>
<p className="mt-4 text-sm leading-relaxed text-white/70">
<p className="mt-4 text-sm leading-relaxed text-brand-700">
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
Stichtag.
</p>
</div>
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
<p className="relative text-xs font-semibold text-brand-700">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
</aside>
);
}
function Wordmark({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center gap-2.5 ${className}`}>
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
drei Einheiten. */}
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
</svg>
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
</div>
);
}
// Die vier Quadrate, die hier als Platzhalter-Bildmarke standen, sind
// entfallen: an ihrer Stelle steht jetzt das Manner-Logo
// (components/brand/Logo.tsx).

View File

@@ -4,10 +4,11 @@ import { callFunction } from "@/lib/db/rpc";
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
// ändern with a future "Wirksam ab" date) once their date has arrived — see
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron
// job (see vercel.json), not on behalf of any HR user, so it authenticates
// via a shared secret rather than a Supabase session and uses the
// service-role client (the one legitimate server-only use case for it).
// apply_due_pending_changes() in db/migrations.
//
// Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00.
// Nicht im Namen einer HR-Person: es gibt keine angemeldete Sitzung, deshalb
// weist sich der Aufruf mit einem gemeinsamen Geheimnis aus (CRON_SECRET).
export async function GET(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {

View File

@@ -5,11 +5,13 @@ import { fmtName, todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { sortiereWochentage } from "@/lib/wochentage";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import type { Database, Weekday } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
// über die Planstelle und die abgeleitete Berichtslinie.
@@ -49,7 +51,15 @@ export async function GET(request: NextRequest) {
function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (!asOf) q = q.where("status", "in", statuses);
// Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert,
// mit Stichtag weiter unten über die Ableitung. Das sind zwei
// verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach,
// sobald ein Austritt mit einem damals künftigen Datum erfasst wurde
// (Migration 20260814100000 zieht sie nicht nach). Der Export nach
// „Ausgetreten" liess damit genau die Leute aus, die gerade
// ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL
// und einmal im Speicher.
if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true));
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
@@ -105,8 +115,6 @@ export async function GET(request: NextRequest) {
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function employeeExportColumns(
lookups: OrgLookups,
managerName: Map<string, string>,
@@ -139,18 +147,31 @@ function employeeExportColumns(
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
// work_days is stored in click order (see RoleEmploymentFields), not
// guaranteed chronological — re-sort Mo→So for the export.
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
// Seit lib/wochentage.ts wird sortiert gespeichert; der Bestand kann aber
// noch unsortierte Zeilen aus der Zeit davor tragen. Hier bleibt es
// deshalb stehen — im Export kostet es nichts und macht die Spalte
// unabhängig davon, wann eine Zeile zuletzt gespeichert wurde.
{ header: "Arbeitstage", get: (e) => sortiereWochentage(e.work_days).join(", ") },
{ header: "Vertragsart", get: (e) => e.contract_type },
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
{ header: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type },
{ header: "Beschäftigtengruppe", get: (e) => e.worker_type },
{ header: "Mitarbeiterart", get: (e) => e.mitarbeiterart },
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
{ header: "Personenkreis", get: (e) => e.kuendigungsschutz_grund ?? "" },
{ header: "Kündigungsschutz ab", get: (e) => e.kuendigungsschutz_ab, kind: "date" },
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
// Der Bericht, den der Workshop verlangt hat (Anforderung 5a): wer ist
// begünstigt behindert, mit wieviel Prozent, ab und bis wann. Vier
// Spalten statt einer zusammengesetzten — in Excel wird danach gefiltert
// und summiert, und ein Text wie „50% seit 2019" liesse beides nicht zu.
{ header: "Begünstigt behindert", get: (e) => e.ist_beguenstigt_behindert },
{ header: "Grad der Behinderung (%)", get: (e) => e.behinderung_grad ?? "" },
{ header: "Bescheid ab", get: (e) => e.behinderung_ab, kind: "date" },
{ header: "Bescheid bis", get: (e) => e.behinderung_bis, kind: "date" },
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
@@ -168,6 +189,9 @@ function employeeExportColumns(
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
{ header: "Austrittsgrund", get: (e) => e.exit_reason },
// Eigene Spalte neben dem Grund: aus ihm folgt sie nicht. Leer heisst
// „nicht erfasst" und nicht „weder noch".
{ header: "Freiwillig/unfreiwillig", get: (e) => e.austrittsart ?? "" },
{ header: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_date, kind: "date" },
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },

View File

@@ -1,6 +1,13 @@
import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
import {
EVENT_TYPE_LABELS,
parseEventDateParam,
parseEventSubtype,
parseEventType,
type OrgLookups,
type ReportEvent,
} from "@/lib/reports";
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
@@ -22,6 +29,10 @@ export async function GET(request: NextRequest) {
loadOrgLookups(tx),
loadEventHistory(tx, {
eventType: eventType ?? undefined,
// Ohne diese Zeile exportierte die Schaltfläche neben der Auswertung
// etwas anderes, als auf dem Bildschirm steht — die Einschränkung auf
// die Beendigungsart fiele beim Export still weg.
eventSubtype: parseEventSubtype(params.get("eventSubtype"), eventType) ?? undefined,
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
from: parseEventDateParam(params.get("from")),
@@ -43,6 +54,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
return [
{ header: "Datum", get: (e) => e.event_date, kind: "date" },
{ header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type },
// Beendigungsart bzw. Art der Langzeitabwesenheit. Leer bei allen
// Ereignissen, die keinen Untertyp kennen — eine Versetzung hat keinen.
{ header: "Untertyp", get: (e) => e.subtype ?? "" },
{ header: "Vorname", get: (e) => e.first_name },
{ header: "Nachname", get: (e) => e.last_name },
{ header: "Position", get: (e) => e.job_title },

View File

@@ -25,10 +25,10 @@ class Rueckabwicklung extends Error {
}
}
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist —
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300
// heben, falls eine Datei mit vielen tausend Zeilen ansteht.
// Im eigenen Container wirkt diese Angabe nicht — sie richtet sich an
// serverlose Plattformen, die einen Aufruf nach Ablauf abschneiden. Sie bleibt
// als Absichtserklärung stehen: ein Import, der länger als eine Minute
// braucht, ist einer, der in Teilen laufen sollte.
export const maxDuration = 60;
type Antwort = {

BIN
app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -6,6 +6,12 @@ import { useEffect } from "react";
// (app)/error.tsx is not mounted yet. It replaces <html>, so it cannot use
// the app's fonts, Tailwind layer or shared components — hence the inline
// styles. Kept deliberately plain; anything clever here can fail too.
//
// Die Farben sind dieselben wie in app/globals.css, nur von Hand: surface,
// border, ink, ink-body, ink-muted und brand-500. Sie stehen hier doppelt,
// weil diese Seite gerade dann erscheint, wenn das Stylesheet nicht
// getragen hat — eine Variable waere hier genau das, worauf man sich nicht
// verlassen kann.
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error("Global error:", error);
@@ -13,19 +19,19 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
return (
<html lang="de-AT">
<body style={{ margin: 0, backgroundColor: "#f9f1f5", fontFamily: "system-ui, sans-serif", color: "#2d1c26" }}>
<div style={{ maxWidth: 480, margin: "15vh auto", padding: 24, background: "#fff", border: "1px solid #eedde6", borderRadius: 8 }}>
<body style={{ margin: 0, backgroundColor: "#fdf6f4", fontFamily: "system-ui, sans-serif", color: "#231f20" }}>
<div style={{ maxWidth: 480, margin: "15vh auto", padding: 24, background: "#fff", border: "1px solid #f0dbd4", borderRadius: 8 }}>
<h1 style={{ fontSize: 18, margin: "0 0 8px" }}>Die Anwendung konnte nicht geladen werden</h1>
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#503b47", margin: "0 0 16px" }}>
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#4b4446", margin: "0 0 16px" }}>
Es ist ein unerwarteter Fehler aufgetreten. Ihre Daten sind davon nicht betroffen.
</p>
{error.digest && (
<p style={{ fontSize: 12, fontFamily: "monospace", color: "#7a636f", margin: "0 0 16px" }}>Fehlerkennung: {error.digest}</p>
<p style={{ fontSize: 12, fontFamily: "monospace", color: "#756c6e", margin: "0 0 16px" }}>Fehlerkennung: {error.digest}</p>
)}
<button
onClick={reset}
style={{
background: "#d6046e",
background: "#164194",
color: "#fff",
border: "none",
borderRadius: 4,

View File

@@ -1,23 +1,74 @@
@import "tailwindcss";
/* Design tokens from spec §7 (ported from tailwind.config.ts for Tailwind v4) */
/* ═══ Farben nach dem Manner CD Manual 2022 ═══════════════════════════
§1.1 kennt genau zwei Markenfarben:
Manner Rosa #F69686 CMYK 0/50/40/0 sRGB 246 150 134
Manner Blau #164194 CMYK 100/80/0/0 sRGB 22 65 148 (Reflex Blue)
Beide behalten ihren Wert unveraendert; die Abstufungen daneben sind
Aufhellungen und Abdunklungen davon, weil eine Oberflaeche Zwischentoene
fuer Hover, Chips und Flaechen braucht und das Manual nur fuer den Druck
geschrieben ist.
── Warum Blau die Interaktion traegt und Rosa die Flaeche ──
Nachgerechnet nach WCAG 2.1:
Weiss auf Rosa 2.18:1 faellt durch, auch fuer Grossschrift
Schwarz auf Rosa 7.47:1 AAA
Weiss auf Blau 9.47:1 AAA
Blau auf Weiss 9.47:1 AAA
Rosa kann also keine Schrift tragen — weder weisse noch, bei 4.34:1,
blaue Fliesschrift. Es ist eine Flaeche, auf der Dunkles steht. Blau
traegt in beide Richtungen und wird damit zur Farbe von Knopf, Link und
Fokusring. Das ist keine Auslegung des Manuals, sondern das, was die
beiden Farben physikalisch hergeben. */
@theme {
--color-brand-50: #fdf3f8;
--color-brand-100: #fdeaf3;
--color-brand-200: #f6cfe2;
--color-brand-500: #d6046e;
--color-brand-600: #b0035a;
--color-brand-700: #a30354;
/* ── Blau: alles, was man anfassen kann ── */
--color-brand-50: #eef3fb;
--color-brand-100: #dae4f5;
--color-brand-200: #afc3e6;
--color-brand-500: #164194; /* Manner Blau, CD §1.1 */
--color-brand-600: #11326f;
--color-brand-700: #0d2857;
--color-surface: #f9f1f5;
/* ── Rosa: alles, was die Marke traegt ──
accent-500 ist der Markenwert und bleibt Flaeche. Die Schrift darauf
ist ink (7.47:1), nie Weiss. */
--color-accent-50: #fef5f2;
--color-accent-100: #fde7e1;
--color-accent-200: #facec2;
--color-accent-500: #f69686; /* Manner Rosa, CD §1.1 */
--color-accent-600: #d9604b;
--color-accent-700: #a8402e;
--color-border: #eedde6;
--color-border-subtle: #f7e8ef;
--color-surface: #fdf6f4;
--color-ink: #2d1c26;
--color-ink-body: #503b47;
--color-ink-muted: #7a636f;
--color-border: #f0dbd4;
--color-border-subtle: #f8ece8;
/* Fuer die Umrandung von Eingabefeldern. Der dekorative Kartenrahmen
darueber darf blass sein; die Grenze eines Bedienelements muss nach
WCAG 1.4.11 3:1 erreichen, und das tut dieser Ton mit 3.56:1 gegen
Weiss und 3.34:1 gegen die Seitenflaeche. */
--color-border-strong: #a67f70;
/* Das Schwarz aus dem Manual selbst (#231F20), nicht reines Schwarz. */
--color-ink: #231f20;
--color-ink-body: #4b4446;
--color-ink-muted: #756c6e;
/* ── Funktionsfarben ──
Sie bleiben, was sie sind. Das Manual regelt die Identitaet, nicht die
Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt,
nicht weil es jemand entworfen hat. In einem System, in dem ein Knopf
einen Austritt erfasst, waere eine markenkonforme Fehlerfarbe kein
Stil, sondern ein Mangel.
`info` bleibt bewusst tuerkis statt blau zu werden: Blau heisst ab
jetzt „hier kann man klicken", und ein Hinweischip in derselben Farbe
saehe bedienbar aus. Gemessen kommt dazu, dass ein blaues info dem
violetten Chip zu nahe kaeme (dE 6.8 — nicht unterscheidbar). */
--color-success-bg: #dff6dd;
--color-success-text: #0e700e;
@@ -31,8 +82,11 @@
--color-info-bg: #d7f0f0;
--color-info-text: #00666d;
--color-purple-bg: #f0ecf7;
--color-purple-text: #5c2e91;
/* Nachgezogen: gegen den alten Wert #f0ecf7 stand der Fehler-Chip bei
dE 8.0 — „Austritt" und „Befoerderung" waren im Vorbeigehen dieselbe
blasse Flaeche. Jetzt dE 15.0 bei 7.9:1 Kontrast. */
--color-purple-bg: #e4d9f5;
--color-purple-text: #54277f;
/* A bare `--radius` in Tailwind v4 collapses the whole scale onto one
value — `rounded` and `rounded-lg` both came out at 8px, so a chip, an
@@ -46,12 +100,17 @@
/* Warm, brand-tinted rather than neutral grey: a pure black shadow over
the pink surface reads as dirt. Kept shallow — this is a data-dense
admin UI, the depth only has to separate a card from the page. */
--shadow-card: 0 1px 2px rgb(107 33 71 / 0.04), 0 1px 3px rgb(107 33 71 / 0.06);
--shadow-card-hover: 0 2px 4px rgb(107 33 71 / 0.06), 0 4px 12px rgb(107 33 71 / 0.08);
--shadow-overlay: 0 8px 32px rgb(45 28 38 / 0.16);
admin UI, the depth only has to separate a card from the page.
Der Grundton folgt dem Rosa der Flaeche, nicht mehr dem alten Magenta. */
--shadow-card: 0 1px 2px rgb(122 60 45 / 0.05), 0 1px 3px rgb(122 60 45 / 0.07);
--shadow-card-hover: 0 2px 4px rgb(122 60 45 / 0.07), 0 4px 12px rgb(122 60 45 / 0.09);
--shadow-overlay: 0 8px 32px rgb(35 31 32 / 0.18);
--font-sans: var(--font-nunito), system-ui, sans-serif;
/* Siehe app/layout.tsx: DIN waere die Schrift des Manuals, ist aber nicht
als Webfont lizenziert; geladen wird Barlow als naechstliegender freier
Ersatz. Die Ersatzkette bleibt neutral — faellt der Webfont aus, soll
keine gerundete Systemschrift einspringen. */
--font-sans: var(--font-din), "Segoe UI", system-ui, sans-serif;
}
body {

315
app/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -1,11 +1,33 @@
import type { Metadata, Viewport } from "next";
import { Nunito } from "next/font/google";
import { Barlow } from "next/font/google";
import { ToastProvider } from "@/components/ui/Toast";
import "./globals.css";
const nunito = Nunito({
variable: "--font-nunito",
subsets: ["latin"],
// ═══ Die Schrift ═══
//
// Das CD Manual ist selbst in DIN gesetzt: nachgezaehlt tragen DIN-Regular
// 79 % und DIN-Bold 16 % des Textes, zusammen 95 %. Helvetica Neue taucht nur
// in den Visitenkarten-Mustern und im Claim auf.
//
// DIN laesst sich hier trotzdem nicht einsetzen: FF DIN und DIN Next sind
// lizenzpflichtig, und eine Druck-Lizenz deckt die Auslieferung als Webfont
// nicht ab — das ist eine eigene, nach Domain oder Abrufen bemessene Lizenz.
// Die freien Nachbauten der DIN 1451 sind Schilderschriften: enge Punzen,
// wenige Schnitte, und bei 14 px in einer Tabelle mit achthundert Zeilen
// schlechter lesbar als das, was sie ersetzen sollen.
//
// Barlow ist die naechstliegende freie Schrift mit UI-Tauglichkeit: dieselbe
// Grotesk-Familie, schmale Proportionen, geringer Strichkontrast, gerade
// Abschluesse — und neun Schnitte mit vollem Latin-Extended, also allen
// Umlauten.
//
// Die Variable heisst nach der Absicht und nicht nach dem Ersatz: liegt
// eines Tages eine Web-Lizenz fuer DIN vor, ist der Wechsel diese eine
// Deklaration und sonst nichts.
const din = Barlow({
variable: "--font-din",
// latin-ext wegen der Umlaute und des scharfen S, die in Namen vorkommen.
subsets: ["latin", "latin-ext"],
weight: ["400", "600", "700", "800"],
});
@@ -15,6 +37,11 @@ export const metadata: Metadata = {
// Added to the home screen on iOS this opens without Safari's chrome and
// keeps the app's own name rather than the page title.
appleWebApp: { capable: true, title: "Alpenwerk HR", statusBarStyle: "default" },
// app/favicon.ico, app/icon.svg und app/apple-icon.png findet Next von
// selbst; die Datei-Konventionen brauchen keinen Eintrag hier. Der Verweis
// auf das Manifest dagegen schon — app/manifest.webmanifest wird sonst
// zwar ausgeliefert, aber von keiner Seite genannt.
manifest: "/manifest.webmanifest",
};
export const viewport: Viewport = {
@@ -23,7 +50,9 @@ export const viewport: Viewport = {
// The layout extends under the notch and home indicator; every edge that
// matters pads itself back out with env(safe-area-inset-*).
viewportFit: "cover",
themeColor: "#ffffff",
// Manner Rosa (CD §1.1): die Farbe, in der mobile Browser ihre eigene
// Leiste ueber der Seite einfaerben. Weiss war hier nur die Vorgabe.
themeColor: "#F69686",
};
export default function RootLayout({
@@ -32,7 +61,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="de-AT" className={`${nunito.variable} h-full antialiased`}>
<html lang="de-AT" className={`${din.variable} h-full antialiased`}>
<body className="flex min-h-dvh flex-col font-sans">
<ToastProvider>{children}</ToastProvider>
</body>

29
app/manifest.webmanifest Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "Alpenwerk HR",
"short_name": "Alpenwerk HR",
"description": "Interne HR-Stammdatenverwaltung",
"icons": [
{
"src": "/brand/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/brand/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/brand/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#F69686",
"background_color": "#FDF6F4",
"display": "standalone",
"start_url": "/"
}

View File

@@ -0,0 +1,57 @@
import { redirect } from "next/navigation";
import { PasswortAendernFormular } from "@/components/auth/PasswortAendernFormular";
import { CARD_CLASS } from "@/components/ui/Card";
import { currentUserId } from "@/lib/auth/session";
import { sql, withUser } from "@/lib/db";
// Diese Seite liegt **ausserhalb** der Gruppe (app), und das ist der Punkt.
//
// app/(app)/layout.tsx lässt nur durch, wer aktive HR-Person ist — und genau
// das ist man nicht, solange ein Passwortwechsel aussteht: is_hr_user() liefert
// dann false (Migration 20260908120000). Läge diese Seite unter (app), wäre sie
// für die einzigen Menschen gesperrt, die sie brauchen.
//
// Angemeldet sein muss man trotzdem: darum kümmert sich proxy.ts, der jede
// Adresse ausser /login und /api/auth hinter eine Sitzung stellt. Die Prüfung
// unten ist die zweite Linie für den Fall, dass jemand den Matcher ändert.
export const metadata = { title: "Passwort ändern" };
export default async function PasswortAendernPage() {
const userId = await currentUserId();
if (!userId) redirect("/login");
// Erzwungen oder freiwillig — davon hängt nur die Beschriftung ab und ob es
// einen Weg zurück gibt.
//
// Ob dieses Konto überhaupt ein Passwort hat, wird hier *nicht* gefragt:
// app_passwoerter ist für die Anwendungsrolle unerreichbar (Policy
// `using (false)`), und eine Auskunftsfunktion nur für diese Anzeige wäre
// eine Funktion zu viel. Wer kein Passwort hat, bekommt beim Absenden die
// Meldung aus app_passwort_aendern().
const erzwungen = await withUser(userId, async (tx) => {
const ergebnis = await sql<{ wechseln: boolean }>`select app_muss_passwort_wechseln() as wechseln`.execute(tx);
return ergebnis.rows[0]?.wechseln === true;
});
return (
<main className="flex min-h-dvh items-center justify-center px-6 py-12">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-extrabold tracking-tight text-ink">Passwort ändern</h1>
{erzwungen ? (
<p className="mt-1.5 text-sm text-ink-muted">
Ihr Zugang wurde mit einem gemeinsamen Startpasswort eingerichtet. Solange es gilt, bleiben alle
Personaldaten verschlossen — bitte vergeben Sie jetzt ein eigenes.
</p>
) : (
<p className="mt-1.5 text-sm text-ink-muted">Sie können hier jederzeit ein neues Passwort vergeben.</p>
)}
<div className={`mt-7 p-5 ${CARD_CLASS}`}>
<PasswortAendernFormular erzwungen={erzwungen} />
</div>
</div>
</main>
);
}

75
auth.ts
View File

@@ -1,5 +1,6 @@
import "server-only";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { authConfig } from "@/lib/auth/config";
import { asSystem, sql } from "@/lib/db";
@@ -9,6 +10,14 @@ import { asSystem, sql } from "@/lib/db";
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
// versteht.
//
// Seit 08.09.2026 steht daneben ein zweiter Weg: Anmeldung mit Passwort, für
// die Testphase und mit eingebautem Ende (siehe Migration 20260908120000).
// Auch er gehört **hierher** und nicht nach lib/auth/config.ts, denn auch er
// spricht mit der Datenbank. Der Proxy sieht ihn nicht und braucht ihn nicht:
// er liest nur das Sitzungscookie, und das ist ein signiertes Token, dessen
// Entschlüsselung von der Anbieterliste unabhängig ist. Welcher Weg zur
// Sitzung geführt hat, steht darin nicht — und muss es auch nicht.
/**
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
@@ -31,21 +40,81 @@ async function upsertAppUser(externalId: string, email: string, fullName: string
return row.id;
}
/**
* Anmeldung mit Passwort.
*
* Die Prüfung selbst steht vollständig in app_passwort_pruefen(): Sperre nach
* fünf Fehlversuchen, Frist für das Initialpasswort, Enddatum des
* Passwortpfads, Protokolleintrag. Hier bleibt nur das Weiterreichen — und
* das ist Absicht. Eine zweite Fassung der Regeln im Anwendungscode wäre die
* Fassung, die zuerst veraltet, und sie liesse sich umgehen, indem jemand die
* Datenbankfunktion direkt aufruft.
*
* Die Funktion liefert eine Kennung oder null und **nie einen Grund**. Was
* hier ankommt, reicht deshalb nicht aus, um zu unterscheiden, ob die Adresse
* unbekannt, das Passwort falsch oder das Konto gesperrt war. Genau so soll
* die Anmeldemaske antworten.
*/
const passwortAnbieter = Credentials({
id: "passwort",
name: "Passwort",
credentials: {
email: { label: "E-Mail", type: "email" },
passwort: { label: "Passwort", type: "password" },
},
async authorize(eingabe) {
const email = typeof eingabe?.email === "string" ? eingabe.email : "";
const passwort = typeof eingabe?.passwort === "string" ? eingabe.passwort : "";
if (!email || !passwort) return null;
// asSystem, weil es noch keine angemeldete Person gibt — die entsteht ja
// erst aus dem Ergebnis. Dieselbe Rolle ohne BYPASSRLS wie überall; die
// Funktion ist SECURITY DEFINER und darf genau das eine.
const zeile = await asSystem(async (tx) => {
const ergebnis = await sql<{ id: string | null }>`
select app_passwort_pruefen(${email}, ${passwort}) as id
`.execute(tx);
return ergebnis.rows[0];
});
if (!zeile?.id) return null;
return { id: zeile.id, email, name: null };
},
});
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
const base = authConfig();
return {
...base,
// Entra bleibt der erste Eintrag und damit der Hauptweg. Die Basisliste
// wird ergänzt, nicht ersetzt: `providers: [passwortAnbieter]` hätte die
// Firmenanmeldung stillschweigend entfernt.
providers: [...base.providers, passwortAnbieter],
callbacks: {
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
...base.callbacks,
async jwt({ token, profile }) {
async jwt({ token, profile, user }) {
// Drei Fälle, und die Reihenfolge trägt sie:
//
// 1. Entra, erster Durchlauf → `profile` liegt vor (und `user` auch)
// 2. Passwort, erster Durchlauf → nur `user`, aus authorize()
// 3. jeder weitere Aufruf → keins von beiden, Token durchreichen
//
// Deshalb wird `profile` zuerst geprüft: bei der Anmeldung über Entra
// ist `user` ebenfalls gesetzt, und eine Prüfung auf `user` zuerst
// führte den Entra-Weg an app_upsert_user() vorbei — die Kennung wäre
// dann die `oid` statt app_users.id, und keine einzige Policy fände
// dazu eine Zeile.
if (!profile) {
if (user?.id) token.uid = user.id;
return token;
}
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
if (!profile) return token;
const externalId = typeof profile.oid === "string" ? profile.oid : null;
const email = [profile.email, profile.preferred_username, profile.upn].find(
(v): v is string => typeof v === "string" && v.length > 0

View File

@@ -5,7 +5,7 @@ import { useState } from "react";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { SlideOver } from "@/components/ui/SlideOver";
import { actionBadgeStyle } from "@/lib/colors";
import type { AuditChange } from "@/lib/supabase/types";
import type { AuditChange } from "@/lib/types";
// Eine Protokollzeile zum Aufklappen.
//
@@ -49,7 +49,7 @@ export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
>
<span>{eintrag.details ?? "–"}</span>
{anzahl > 0 && (
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
<span className="ml-2 whitespace-nowrap rounded-full bg-accent-100 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
</span>
)}

View File

@@ -0,0 +1,105 @@
"use client";
import Link from "next/link";
import { useActionState, useState } from "react";
import { passwortAendern, type WechselZustand } from "@/actions/passwort";
import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS, Field } from "@/components/ui/Field";
import { PASSWORT_REGELN, passwortBeanstandung } from "@/lib/passwort";
// Das eigene Passwort wechseln.
//
// Die Regeln stehen schon beim Tippen da, statt erst nach dem Absenden — beim
// erzwungenen Wechsel ist das der erste Bildschirm, den jemand in dieser
// Anwendung sieht, und dreimal abgewiesen zu werden ist ein schlechter Anfang.
// Abgewiesen oder angenommen wird trotzdem in der Datenbank; hier steht nur
// eine Vorabanzeige (lib/passwort.ts).
const START: WechselZustand = { fehler: null };
export function PasswortAendernFormular({ erzwungen }: { erzwungen: boolean }) {
const [zustand, aktion, laeuft] = useActionState(passwortAendern, START);
const [neues, setNeues] = useState("");
const [wiederholung, setWiederholung] = useState("");
// Erst beanstanden, wenn etwas dasteht: ein leeres Feld ist noch kein Fehler.
const beanstandung = neues ? passwortBeanstandung(neues) : null;
const passtNichtZusammen = wiederholung.length > 0 && neues !== wiederholung;
return (
<form action={aktion} className="flex flex-col gap-4">
{zustand.fehler && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
{zustand.fehler}
</p>
)}
<Field label="Bisheriges Passwort">
{(p) => (
<input {...p} name="altes_passwort" type="password" required autoComplete="current-password" className={CONTROL_CLASS} />
)}
</Field>
<Field
label="Neues Passwort"
error={beanstandung}
hint={`Erforderlich: ${PASSWORT_REGELN.join(", ")}.`}
>
{(p) => (
<input
{...p}
name="neues_passwort"
type="password"
required
autoComplete="new-password"
value={neues}
onChange={(e) => setNeues(e.target.value)}
className={CONTROL_CLASS}
/>
)}
</Field>
<Field label="Neues Passwort wiederholen" error={passtNichtZusammen ? "Die beiden Eingaben stimmen nicht überein." : null}>
{(p) => (
<input
{...p}
name="wiederholung"
type="password"
required
autoComplete="new-password"
value={wiederholung}
onChange={(e) => setWiederholung(e.target.value)}
className={CONTROL_CLASS}
/>
)}
</Field>
{/* Gesperrt, solange die Vorabanzeige etwas zu beanstanden hat. Das ist
Bequemlichkeit, keine Absicherung — wer die Schaltfläche freischaltet,
landet in app_passwort_regeln() und wird dort abgewiesen. */}
<Button
type="submit"
fullWidth
disabled={Boolean(beanstandung) || passtNichtZusammen}
pending={laeuft}
pendingLabel="Wird gespeichert…"
>
Passwort ändern
</Button>
{/* `Link` und nicht `<a>`: ein gewöhnlicher Verweis auf eine Seite dieser
Anwendung lädt das Dokument neu und damit das ganze Bündel — der Weg
zurück fühlte sich an wie ein Neustart. eslint-config-next weist das
ab, und zu Recht. */}
{!erzwungen && (
<Link
href="/"
className="rounded text-center text-xs text-ink-muted underline underline-offset-2 hover:no-underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Zurück zur Anwendung
</Link>
)}
</form>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useActionState } from "react";
import { anmeldenMitPasswort, type AnmeldeZustand } from "@/actions/auth";
import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS, Field } from "@/components/ui/Field";
// Anmeldung mit Passwort — der zweite Weg neben dem Firmenkonto, auf Zeit
// (siehe actions/auth.ts).
//
// Die Meldung kommt aus der Server Action und ist für jeden Fehlschlag
// dieselbe. Kein `autoFocus`: die Seite bietet zwei Wege an, und der obere ist
// der gemeinte — den Cursor unten hineinzusetzen kehrte das um.
const START: AnmeldeZustand = { fehler: null };
export function PasswortFormular() {
const [zustand, aktion, laeuft] = useActionState(anmeldenMitPasswort, START);
return (
<form action={aktion} className="flex flex-col gap-3">
{zustand.fehler && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
{zustand.fehler}
</p>
)}
<Field label="E-Mail-Adresse">
{(p) => (
<input
{...p}
name="email"
type="email"
required
// `username`, nicht `email`: nur so bietet der Passwortspeicher des
// Browsers das Paar aus Adresse und Passwort gemeinsam an.
autoComplete="username"
className={CONTROL_CLASS}
/>
)}
</Field>
<Field label="Passwort">
{(p) => (
<input {...p} name="passwort" type="password" required autoComplete="current-password" className={CONTROL_CLASS} />
)}
</Field>
<Button type="submit" fullWidth pending={laeuft} pendingLabel="Anmelden…">
Anmelden
</Button>
</form>
);
}

94
components/brand/Logo.tsx Normal file
View File

@@ -0,0 +1,94 @@
// Das Manner-Logo und die Wortmarke der Anwendung — an einer Stelle, weil
// Kopfleiste und Anmeldeseite dasselbe zeigen sollen.
//
// ═══ Welches Logo ═══
//
// Verwendet wird das **Markenlogo** (CD §4.1): der Schriftzug allein, ohne
// den Stephansdom-Medaillon. Das Unternehmenslogo (§3.1) waere nach §3.2 das
// formal richtige, wo „Josef Manner & Comp. AG" als Absender auftritt — es
// steht hier aber nicht zur Verfuegung.
//
// ═══ Eine Datei, zwei Faelle ═══
//
// Die Vorlage ist ein PNG mit durchsichtigem Grund: nur der Schriftzug, blau
// (#164194, der Wert aus §1.1) mit weisser Kontur, kein Feld darunter.
//
// §1.2 laesst den Schriftzug ausschliesslich zur Gaenze auf Rosa zu, und das
// Manual kennt dafuer zwei Lagen:
//
// • **In einem grossflaechigen rosa Umfeld** (§3.1) wird er „mit genuegend
// Freiraum rundum integriert" — die Flaeche ist schon da. Das ist
// `aufRosa`: nur Freiraum, kein eigener Grund.
//
// • **Auf Weiss** (§4.1) gehoert er „in ein rechteckiges rosa Feld, das den
// Regeln des Freiraums folgt". Dieses Feld zeichnet das Bauteil selbst,
// aus derselben Polsterung.
//
// Vorher lagen dafuer zwei SVG nebeneinander, eines mit eingebackenem Feld
// und eines ohne. Das war eine Datei zu viel — und die Fassung mit Feld liess
// sich auf einer rosa Flaeche nicht sauber unterbringen, weil ihr Rechteck
// sichtbar blieb, sobald ueber der Flaeche noch irgendetwas lag.
//
// Als <img> und nicht inline: der Browser holt die Datei einmal und nimmt sie
// danach aus dem Zwischenspeicher, statt sie in jeder Seitenauslieferung
// mitzuschleppen.
/** Seitenverhaeltnis der Vorlage: 1203 x 484 Pixel. */
const VERHAELTNIS = 1203 / 484;
export function MannerLogo({
hoehe = 28,
aufRosa = false,
className = "",
}: {
hoehe?: number;
/** Fuer rosa Untergruende: ohne eigenes Feld, nur mit Freiraum. */
aufRosa?: boolean;
className?: string;
}) {
const breite = Math.round(hoehe * VERHAELTNIS);
// Der Freiraum nach §4.1: X/3, wobei X als Hoehe des Schriftzugs gelesen
// ist. Er steckt hier und nicht in den Aufrufstellen — sonst haengt seine
// Einhaltung daran, dass jede einzelne daran denkt.
const freiraum = Math.round(hoehe / 3);
return (
<span
style={{ padding: freiraum }}
// Ohne Eckenrundung: §4.1 spricht von einem rechteckigen Feld.
className={`inline-flex shrink-0 items-center ${aufRosa ? "" : "bg-accent-500"} ${className}`}
>
{/* next/image bringt Groessenvarianten und Ladelogik fuer Fotos mit; fuer
eine Bildmarke fester Groesse ist beides Ballast. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/brand/manner-logo.png" alt="Manner" width={breite} height={hoehe} style={{ width: breite, height: hoehe }} />
</span>
);
}
/**
* Logo plus Produktname.
*
* Die beiden gehoeren zusammen und meinen Verschiedenes: das Logo sagt, wem
* das System gehoert, der Name sagt, was es ist.
*/
export function AppWortmarke({
hoehe = 26,
aufRosa = false,
textKlasse = "text-ink",
className = "",
}: {
hoehe?: number;
aufRosa?: boolean;
textKlasse?: string;
className?: string;
}) {
return (
// Nur ein schmaler Abstand: den Hauptteil der Trennung leistet der
// Freiraum des Logos, der ohnehin frei bleiben muss.
<span className={`flex items-center gap-2 ${className}`}>
<MannerLogo hoehe={hoehe} aufRosa={aufRosa} />
<span className={`text-base font-extrabold tracking-tight ${textKlasse}`}>Alpenwerk HR</span>
</span>
);
}

View File

@@ -2,7 +2,7 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { actionBadgeStyle } from "@/lib/colors";
import { ANSTEHEND_STYLES } from "@/lib/colors";
import {
ANSTEHEND_ARTEN,
STANDARD_ZEITRAUM,
@@ -57,7 +57,7 @@ export function AnstehendFilter({ zeitraum, arten }: { zeitraum: Zeitraum; arten
onClick={() => artUmschalten(a.value)}
aria-pressed={aktiv}
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
aktiv ? actionBadgeStyle(a.label) : "bg-surface text-ink-muted hover:text-ink"
aktiv ? ANSTEHEND_STYLES[a.value] : "bg-surface text-ink-muted hover:text-ink"
}`}
>
{a.label}

View File

@@ -0,0 +1,148 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { fmtDate } from "@/lib/format";
export const KIND_LABEL = {
hire: "Eintritt",
exit: "Austritt",
return: "Rückkehr aus Abwesenheit",
note: "Wiedervorlage",
} as const;
export type AnstehendEintrag = {
id: string;
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
employeeId: string;
label: string;
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
hinweis?: string;
date: string;
kind: keyof typeof KIND_LABEL;
};
/** Wie viele Zeilen die Karte zeigt, bevor aufgeklappt wird. */
export const VORSCHAU = 8;
/**
* Die Karte bleibt eine Übersicht — acht Zeilen, und darunter steht, wie
* viele es insgesamt sind. Neu ist, dass diese Zeile ein Knopf ist: wer alle
* sehen will, klappt sie auf, statt den Zeitraum enger zu stellen und die
* Antwort damit zu verändern.
*
* Das geschieht im Browser und nicht über die Adresse, anders als bei
* Zeitraum und Art: die Einträge sind bereits alle geladen, es gibt nichts
* nachzuholen. Ein Umweg über den Server brächte eine Wartezeit für Daten,
* die schon da sind — und einen Eintrag im Verlauf für etwas, das niemand
* zurückgehen will.
*/
export function AnstehendListe({
eintraege,
today,
leerText,
}: {
eintraege: AnstehendEintrag[];
today: string;
leerText: string;
}) {
const [alle, setAlle] = useState(false);
// Namensfilter aus dem Workshop (Anforderung 2). Im Browser und nicht über
// die Adresse, aus demselben Grund wie das Aufklappen: die Einträge sind
// vollständig geladen, es gibt nichts nachzuholen.
const [suche, setSuche] = useState("");
const begriff = suche.trim().toLowerCase();
// Wortweise und am Wortanfang — dieselbe Regel wie in der Mitarbeiterliste,
// damit „gru" Gruber findet und nicht auch jede Notiz mit „Arbeitsgruppe".
// Der Hinweis wird bewusst nicht durchsucht: gesucht wird nach Personen.
const gefiltert = begriff
? eintraege.filter((e) =>
e.label
.toLowerCase()
.split(/[\s,;/-]+/)
.some((wort) => wort.startsWith(begriff))
)
: eintraege;
const sichtbar = alle ? gefiltert : gefiltert.slice(0, VORSCHAU);
const weitere = gefiltert.length - sichtbar.length;
return (
<>
{/* Immer da, sobald überhaupt etwas ansteht.
*
Zuerst stand hier eine Schwelle — erst ab neun Einträgen, weil man
darunter die ganze Liste ohnehin sieht. In der Bedienung war das
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. Wer sucht, sucht auch in sieben Zeilen. */}
{eintraege.length > 0 && (
<input
type="search"
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Name filtern…"
aria-label="Anstehende Ereignisse nach Name filtern"
className="mb-2 w-full rounded border border-border-strong bg-white px-2.5 py-1.5 text-sm text-ink
placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
)}
<ul className="flex flex-col divide-y divide-border-subtle">
{sichtbar.map((item) => {
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
// haben eine untere Grenze, eine offene Aufgabe nicht.
const ueberfaellig = item.date < today;
return (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/employees/${item.employeeId}`}
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
>
<span className="min-w-0">
<span className="block truncate font-semibold text-ink">{item.label}</span>
<span className="block truncate text-xs text-ink-muted">
{KIND_LABEL[item.kind]}
{item.hinweis ? ` — ${item.hinweis}` : ""}
</span>
</span>
<span
className={`shrink-0 text-xs font-semibold tabular-nums ${
ueberfaellig ? "text-danger-text" : "text-ink-muted"
}`}
>
{ueberfaellig ? "überfällig " : ""}
{fmtDate(item.date)}
</span>
</Link>
</li>
);
})}
{eintraege.length === 0 && <p className="py-2 text-sm text-ink-muted">{leerText}</p>}
{/* Ein leeres Ergebnis mit Suchbegriff ist etwas anderes als eine
leere Karte: der Zeitraum stimmt, nur der Name passt nicht. */}
{eintraege.length > 0 && gefiltert.length === 0 && (
<p className="py-2 text-sm text-ink-muted">
Kein Name passt zu „{suche.trim()}&ldquo;. {eintraege.length}{" "}
{eintraege.length === 1 ? "Ereignis steht" : "Ereignisse stehen"} an.
</p>
)}
</ul>
{/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste
aufgeklappt, führt er wieder zurück — sonst bliebe die Karte für den
Rest der Sitzung lang, auch wenn jemand nur einmal nachsehen
wollte. */}
{(weitere > 0 || alle) && (
<button
type="button"
onClick={() => setAlle((v) => !v)}
aria-expanded={alle}
className="mt-2 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{alle
? "Weniger anzeigen"
: `… und ${weitere} ${weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} anzeigen`}
</button>
)}
</>
);
}

View File

@@ -1,16 +1,30 @@
"use client";
import { Trash2 } from "lucide-react";
import { Lock, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { deleteHireDraft } from "@/actions/hireDrafts";
import { useHireWizard } from "@/components/hire/HireWizardContext";
import { useToast } from "@/components/ui/Toast";
import type { Entwurf } from "@/lib/entwuerfe";
import { fmtDate } from "@/lib/format";
type Draft = { id: string; step: number; payload: Record<string, unknown>; updated_at: string };
// Angefangene Neueinstellungen — die eigenen und, wer in der Glocke
// hinzugewählt ist, auch dessen.
//
// Fortsetzen geht bei beiden: wer vertreten wird, soll die angefangene
// Einstellung zu Ende bringen können. Aber immer nur eine Person zugleich —
// der Entwurf ist ein einziges JSONB-Feld, zwei gleichzeitig Schreibende
// überschrieben einander vollständig, und die Zweite merkte nichts davon.
// Die Sperre dazu steht in der Datenbank (20260910160000); hier steht nur,
// was sie für die Zeile bedeutet.
//
// Löschen bleibt beim Eigentümer. Die Regel liesse mehr zu — sie muss, weil
// der Assistent den Entwurf nach dem Anlegen selbst wegräumt —, aber eine
// Schaltfläche, die fremde halbfertige Arbeit wegwirft, gehört nicht neben
// eine, die sie fortsetzt.
export function DraftsCard({ drafts }: { drafts: Draft[] }) {
export function DraftsCard({ drafts }: { drafts: Entwurf[] }) {
const { openWizard } = useHireWizard();
const { showToast } = useToast();
const router = useRouter();
@@ -39,24 +53,46 @@ export function DraftsCard({ drafts }: { drafts: Draft[] }) {
const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : "";
const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen";
return (
<li key={d.id} className="flex items-center justify-between py-2 text-sm">
<li key={d.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div>
<span className="font-semibold text-ink">{name}</span>
<span className="ml-2 text-xs text-ink-muted">Gespeichert am {fmtDate(d.updated_at)}</span>
{/* An jeder Zeile, wie an den Notizen in der Glocke: seit
fremde Entwürfe dazwischenstehen, ist ohne die Angabe nicht
zu sehen, welcher wessen ist. „von mir" statt des eigenen
Namens — den kennt man. */}
<span className="ml-2 text-xs text-ink-muted">{d.vonMir ? "von mir" : `von ${d.autor}`}</span>
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => openWizard({ draftId: d.id })} className="text-xs font-semibold text-brand-700 hover:underline">
{d.gesperrtVon ? (
// Mit dem Grund daneben, nicht bloss abgeschaltet: eine
// graue Schaltfläche ohne Erklärung liest sich wie ein
// Fehler, und die nächste Handlung wäre, es gleich noch
// einmal zu versuchen.
<span className="flex shrink-0 items-center gap-1 text-xs text-ink-muted">
<Lock className="h-3.5 w-3.5" />
{d.gesperrtVon} bearbeitet gerade
</span>
) : (
<button
type="button"
onClick={() => openWizard({ draftId: d.id })}
className="text-xs font-semibold text-brand-700 hover:underline"
>
Fortsetzen
</button>
)}
{d.vonMir && (
<button
type="button"
onClick={() => handleDelete(d.id)}
disabled={pending}
disabled={pending || Boolean(d.gesperrtVon)}
aria-label="Entwurf löschen"
className="text-ink-muted hover:text-danger-solid"
className="text-ink-muted hover:text-danger-solid disabled:opacity-40"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</li>
);

View File

@@ -8,7 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { RelationshipType } from "@/lib/supabase/types";
import type { RelationshipType } from "@/lib/types";
const RELATIONSHIPS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];

View File

@@ -7,7 +7,7 @@ import { deleteEmployeeDependent } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
import { AddDependentModal } from "./AddDependentModal";
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
@@ -52,7 +52,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
<div className="overflow-x-auto rounded border border-border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b border-border bg-brand-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<tr className="border-b border-border bg-accent-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<th className="px-4 py-2.5">Name</th>
<th className="px-4 py-2.5">Verhältnis</th>
<th className="px-4 py-2.5">SVNR</th>

View File

@@ -6,17 +6,23 @@ import { useState } from "react";
import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { StatusChip } from "@/components/ui/StatusChip";
import type { AufgabenStand } from "@/lib/checklist";
import { fmtFullName, tenure } from "@/lib/format";
import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding";
import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
import { deriveStatusAsOf } from "@/lib/reports";
import type { Database } from "@/lib/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel";
import { PromotePanel } from "./panels/PromotePanel";
import { RehirePanel } from "./panels/RehirePanel";
import { RehireWizard } from "../hire/RehireWizard";
import { TerminatePanel } from "./panels/TerminatePanel";
import { TransferPanel } from "./panels/TransferPanel";
import { HistorieTab } from "./tabs/HistorieTab";
import { NotizenTab } from "./tabs/NotizenTab";
import { OffboardingTab } from "./tabs/OffboardingTab";
import { OnboardingTab } from "./tabs/OnboardingTab";
import { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab";
@@ -36,6 +42,10 @@ type EmployeeDetailProps = {
breadcrumb: string;
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
kostenstelle: { code: string; name: string } | null;
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
onboarding: AufgabenStand[];
/** Der Stand der Offboarding-Checkliste; leer heisst: es gibt keine. */
offboarding: AufgabenStand[];
manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
@@ -45,20 +55,37 @@ type EmployeeDetailProps = {
notes: NoteRow[];
locations: Location[];
openPositions: OpenPositionResolved[];
/** Der Tag, zu dem die Akte gelesen wurde — derselbe wie in loadEmployeeDetail. */
today: string;
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
const ALLE_TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Offboarding", "Historie", "HR-Notizen"] as const;
type Tab = (typeof ALLE_TABS)[number];
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions, today } = props;
const [tab, setTab] = useState<Tab>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
const location = locations.find((l) => l.id === employee.location_id);
// Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu
// erfahren, dass nichts zu tun ist.
const offeneOnboarding = onboarding.length > 0 ? fortschrittOnboarding(onboarding).gesamt - fortschrittOnboarding(onboarding).erledigt : 0;
const offeneOffboarding = offboarding.length > 0 ? fortschrittOffboarding(offboarding).gesamt - fortschrittOffboarding(offboarding).erledigt : 0;
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
const canEditData = employee.status !== "Ausgetreten";
const zeigtOffboarding = gehoertOffboarding(employee);
const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding);
// Abgeleitet, nicht aus `employee.status` gelesen. Die Spalte hängt nach,
// wenn ein Austritt mit einem damals künftigen Datum erfasst wurde — es gibt
// keinen Lauf, der sie später nachzieht (Migration 20260814100000). Hier
// entschied sie nicht nur über eine Beschriftung, sondern über die Knöpfe:
// an einer Person, die seit zwei Wochen ausgetreten ist, stand „Austritt"
// weiter zur Verfügung, und „Daten ändern" ebenfalls.
const status = deriveStatusAsOf(employee, today);
const isActive = status === "Aktiv" || status === "Karenz";
const canEditData = status !== "Ausgetreten";
return (
<div className="flex flex-col gap-6">
@@ -75,7 +102,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<h2 className="text-xl font-extrabold text-ink">
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
</h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
<StatusChip employee={employee} asOf={today} />
</div>
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
<p className="text-xs text-ink-muted">
@@ -90,7 +117,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
</p>
<p className="mt-1 text-xs text-ink-muted">
Pers.-Nr. {employee.personnel_number}
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
{status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
</p>
</div>
</div>
@@ -102,7 +129,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
<ActionButton
icon={Clock}
label={employee.status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
label={status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
onClick={() => setPanel("karenz")}
/>
</>
@@ -113,7 +140,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
geplanter Eintritt stehen. */}
{(isActive || employee.status === "Geplant") && (
{(isActive || status === "Geplant") && (
<Button
variant="secondary"
size="sm"
@@ -121,12 +148,22 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
>
<XCircle className="h-4 w-4" />
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
{status === "Geplant" ? "Nicht angetreten" : "Austritt"}
</Button>
)}
{employee.status === "Ausgetreten" && (
{/* Der Knopf hing an `employee.status` — der Spalte, die
nachhängt. An einer Person, deren Austritt erfasst und
inzwischen vollzogen war, stand dort weiter „Aktiv", und der
Wiedereintritt war schlicht nicht erreichbar. Der Kunde hat
ihn deshalb für verschwunden gehalten („ich dachte, das ist
schon implementiert, das war mal drin").
*
Beim Statusfix waren nur `isActive` und `canEditData`
umgestellt worden — die fünf einzelnen Abfragen in dieser
Datei blieben stehen. Jetzt liest keine mehr die Spalte. */}
{status === "Ausgetreten" && (
<Button size="sm" onClick={() => setPanel("rehire")}>
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
<RotateCcw className="h-4 w-4" /> Wiedereintritt
</Button>
)}
</div>
@@ -136,7 +173,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
{/* Tabs, so the list gets the tablist role and each button says
whether it is the selected one. */}
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
{TABS.map((t) => (
{tabs.map((t) => (
<button
key={t}
role="tab"
@@ -146,7 +183,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t}
{t === "HR-Notizen"
? `HR-Notizen ${notes.length}`
: t === "Onboarding" && offeneOnboarding > 0
? `Onboarding ${offeneOnboarding}`
: t === "Offboarding" && offeneOffboarding > 0
? `Offboarding ${offeneOffboarding}`
: t}
</button>
))}
</div>
@@ -164,6 +207,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
kostenstelle={kostenstelle}
/>
)}
{tab === "Onboarding" && (
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
)}
{tab === "Offboarding" && zeigtOffboarding && (
<OffboardingTab employeeId={employee.id} staende={offboarding} vorhanden={offboarding.length > 0} />
)}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
@@ -175,7 +224,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
openPositions={openPositions}
/>
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} status={status} />
<DatenAendernPanel
open={panel === "daten"}
onClose={() => setPanel(null)}
@@ -183,13 +232,27 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
dependents={dependents}
locationCountry={location?.country}
/>
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel
open={panel === "rehire"}
<TerminatePanel
open={panel === "terminate"}
onClose={() => setPanel(null)}
employee={employee}
status={status}
directReportCount={directReports.length}
/>
{/* Der `key` sorgt fürs Zurücksetzen: beim Öffnen entsteht der
Assistent neu und liest die Vorbelegung frisch aus der Akte. Ohne
ihn trüge er nach einem Abbruch die halb ausgefüllten Angaben des
vorigen Anlaufs. */}
{panel === "rehire" && (
<RehireWizard
key={employee.updated_at}
open
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
locations={locations}
/>
)}
</div>
);
}

View File

@@ -19,7 +19,11 @@ type EmployeeFiltersProps = {
const STATUS_OPTIONS = [
{ value: "Aktiv", label: "Aktiv" },
{ value: "Karenz", label: "Langzeitabwesenheit" },
{ value: "Aktiv,Karenz", label: "Aktiv + Langzeitabwesenheit (beschäftigt)" },
// Der Name der Kachel zuerst, die Bestandteile in Klammern dahinter: die
// Kachel „Aktives Dienstverhältnis" verlinkt genau auf diese Auswahl, und
// wer ihr folgt, soll oben dieselbe Bezeichnung wiederfinden statt eine
// zweite für dieselbe Sache.
{ value: "Aktiv,Karenz", label: "Aktives Dienstverhältnis (Aktiv + Langzeitabwesenheit)" },
{ value: "Geplant", label: "Geplant" },
{ value: "Ausgetreten", label: "Ausgetreten" },
] as const;
@@ -104,6 +108,9 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
</option>
))}
</select>
{/* Sortiert wird nicht hier, sondern an den Spaltenköpfen der Liste.
Diese Leiste schränkt ein, *was* zu sehen ist; die Reihenfolge
gehört an die Spalte, die sie bestimmt. */}
{/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —

View File

@@ -9,7 +9,7 @@ import { TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { AuditChange } from "@/lib/supabase/types";
import type { AuditChange } from "@/lib/types";
// Berichtigen, nicht neu erfassen.
//

View File

@@ -0,0 +1,195 @@
"use client";
import { Printer } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@/components/ui/Button";
import {
alleReichungen,
fortschrittVon,
istErledigt,
type AufgabenGruppe,
type AufgabenStand,
} from "@/lib/checklist";
import { artTitel, checklistDateiname, type ChecklistArt } from "@/lib/checklist-print";
import type { ChecklistKopf } from "@/lib/checklist-print-data";
import { fmtDate, fmtName } from "@/lib/format";
// Der Ausdruck einer Checkliste — eine Seite, zum Abheften oder Mitnehmen.
//
// Warum überhaupt Papier, wenn die Liste in der Anwendung steht: sie wird
// besprochen. Beim Eintrittsgespräch liegt sie auf dem Tisch, beim Austritt
// geht sie an die Lohnverrechnung, und in einer Personalakte auf Papier fehlt
// sie sonst. Gedruckt wird deshalb der **Stand**, mit Datum des Auszugs — kein
// Formular zum Ausfüllen.
//
// Erzeugt wird kein PDF im Server: gedruckt wird über den Browser, wie beim
// Organigramm. Der Grund ist derselbe — eine PDF-Bibliothek müsste Schriften,
// Umbrüche und Seitenränder selbst nachbauen, und das Ergebnis wäre ein
// zweites Aussehen neben dem der Anwendung. Der Dateiname im Speichern-Dialog
// kommt aus dem Dokumenttitel; den setzt diese Seite auf die Konvention.
const GLYPH_AN = "☒";
const GLYPH_AUS = "☐";
export function PrintChecklist({
art,
kopf,
gruppen,
staende,
today,
}: {
art: ChecklistArt;
kopf: ChecklistKopf;
gruppen: readonly AufgabenGruppe[];
staende: AufgabenStand[];
/** Vom Server: das Auszugsdatum gehört nicht an die Uhr des Browsers. */
today: string;
}) {
const dateiname = checklistDateiname(art, kopf, today);
// Der Titel ist der Vorschlag im Druckdialog. Ohne ihn hiesse die Datei nach
// der Adresse der Seite, also etwa „employees-…-checkliste".
useEffect(() => {
const vorher = document.title;
document.title = dateiname;
return () => {
document.title = vorher;
};
}, [dateiname]);
const punkte = alleReichungen(gruppen);
const karte = new Map(staende.map((s) => [s.item_key, s]));
const stand = fortschrittVon(punkte, staende);
return (
<div className="flex flex-col gap-5">
<style>{`
@page { size: A4 portrait; margin: 14mm 14mm 12mm; }
@media print {
html, body { background: #fff; }
.nur-bildschirm { display: none !important; }
.blatt {
box-shadow: none !important; border: 0 !important; border-radius: 0 !important;
margin: 0 !important; padding: 0 !important; width: auto !important; min-height: 0 !important;
}
/* Eine Gruppe soll nicht mitten in der Überschrift umbrechen, und
eine Zeile nicht zwischen Haken und Kommentar. */
.gruppe { break-inside: avoid; }
.punkt { break-inside: avoid; }
}
/* Zweispaltig, damit 25 Punkte auf ein Blatt gehen. Als Spaltensatz und
nicht als Gitter: die Punkte fliessen dann von der linken in die
rechte Spalte, statt dass eine Spalte halb leer bleibt. */
.spalten { column-count: 2; column-gap: 10mm; }
`}</style>
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-ink-muted">
So kommt es aus dem Drucker. Vorgeschlagener Dateiname:{" "}
<span className="font-semibold text-ink">{dateiname}.pdf</span>
</p>
<Button onClick={() => window.print()}>
<Printer className="h-4 w-4" />
Als PDF speichern
</Button>
</div>
<p className="nur-bildschirm max-w-prose text-xs leading-relaxed text-ink-muted">
Im Druckdialog &bdquo;Als PDF speichern&ldquo; wählen. Hintergrundgrafiken müssen dort eingeschaltet sein, sonst
fehlen die Flächen hinter den Gruppentiteln &mdash; in Chrome unter &bdquo;Weitere Einstellungen&ldquo;.
</p>
<div className="blatt mx-auto w-[210mm] rounded border border-border bg-white p-[14mm] shadow-[var(--shadow-card)]">
{/* ── Kopf ───────────────────────────────────────────── */}
<div className="flex items-start justify-between gap-6 border-b-2 border-ink pb-2">
<div>
<h1 className="text-[15pt] font-extrabold leading-tight text-ink">{artTitel(art)}</h1>
<p className="text-[9pt] text-ink-muted">Alpenwerk Industrie GmbH</p>
</div>
<p className="text-right text-[8pt] leading-snug text-ink-muted">
Auszug vom
<span className="block text-[10pt] font-semibold tabular-nums text-ink">{fmtDate(today)}</span>
</p>
</div>
<dl className="mt-3 grid grid-cols-[auto_1fr_auto_1fr] gap-x-4 gap-y-1 text-[9pt]">
<dt className="font-semibold text-ink-muted">Pers.-Nr.</dt>
<dd className="tabular-nums text-ink">{kopf.personnel_number}</dd>
<dt className="font-semibold text-ink-muted">Eintritt</dt>
<dd className="tabular-nums text-ink">{fmtDate(kopf.entry_date)}</dd>
<dt className="font-semibold text-ink-muted">Name</dt>
<dd className="font-semibold text-ink">{fmtName(kopf.first_name, kopf.last_name)}</dd>
<dt className="font-semibold text-ink-muted">Position</dt>
<dd className="text-ink">
{kopf.position_title ?? kopf.job_title}
{kopf.position_number && <span className="text-ink-muted"> · Planstelle {kopf.position_number}</span>}
</dd>
</dl>
{/* ── Fortschritt ────────────────────────────────────── */}
<div className="mt-3 flex items-baseline justify-between border-t border-border pt-2 text-[9pt]">
<span className="font-semibold text-ink">
{stand.erledigt} von {stand.gesamt} erledigt
</span>
{stand.offen.length > 0 && (
<span className="text-ink-muted">
{stand.offen.length} offen
</span>
)}
</div>
{/* ── Die Punkte ─────────────────────────────────────── */}
<div className="spalten mt-3">
{gruppen.map((gruppe) => (
<div key={gruppe.key} className="gruppe mb-3">
<h2 className="mb-1 bg-surface px-1.5 py-0.5 text-[8pt] font-bold uppercase tracking-wide text-ink-body">
{gruppe.label}
</h2>
<ul>
{gruppe.punkte.map((punkt) => {
const s = karte.get(punkt.key);
const fertig = istErledigt(punkt, s);
return (
<li key={punkt.key} className="punkt flex gap-1.5 py-[2pt] text-[9pt] leading-snug">
<span aria-hidden className="shrink-0 text-[10pt] leading-none text-ink">
{fertig ? GLYPH_AN : GLYPH_AUS}
</span>
<span className="min-w-0">
<span className={fertig ? "text-ink" : "text-ink-body"}>{punkt.label}</span>
{/* Bei Ja/Nein und Text steht der Wert daneben — ein
Haken allein sagte dort nicht, was erfasst wurde. */}
{punkt.art !== "haken" && (
<span className="font-semibold text-ink">
{": "}
{s?.wert ?? "—"}
</span>
)}
{s?.kommentar && (
<span className="block text-[8pt] italic text-ink-body">{s.kommentar}</span>
)}
{s?.updated_by_name && fertig && (
<span className="block text-[7pt] text-ink-muted">
{s.updated_by_name}
{s.updated_at ? ` · ${fmtDate(s.updated_at)}` : ""}
</span>
)}
</span>
</li>
);
})}
</ul>
</div>
))}
</div>
{/* Hier standen zwei Unterschriftenfelder — „Datum, Unterschrift
Mitarbeiter:in" und „… Personalabteilung". Der Kunde hat sie
gestrichen (Anforderung 3 aus dem Workshop): das Blatt ist eine
Arbeitsliste für die Personalabteilung und kein Dokument, das
jemand gegenzeichnet. Wer eine Aufgabe abgehakt hat, steht ohnehin
an der Aufgabe selbst. */}
</div>
</div>
);
}

View File

@@ -1,12 +1,39 @@
"use client";
import { SelectField, TextField } from "@/components/ui/Field";
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
import {
BEGUENSTIGUNG_AB_GRAD,
GRUND_BEGUENSTIGT_BEHINDERT,
KUENDIGUNGSSCHUTZ_GRUENDE,
} from "@/lib/kuendigungsschutz";
import { MITARBEITERARTEN } from "@/lib/mitarbeiterart";
import { sortiereWochentage, WOCHENTAGE } from "@/lib/wochentage";
import type { CollectiveAgreement, DienstwagenArt, Mitarbeiterart, SourceType, Weekday, WorkerType } from "@/lib/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
export type RoleEmploymentValue = {
workerType: WorkerType;
/**
* Form der Beschäftigung — zweite Achse neben der Gruppe. Ein Praktikant
* kann Arbeiter oder Angestellter sein; in einem Feld liesse sich nur eines
* von beidem sagen.
*/
mitarbeiterart: Mitarbeiterart;
/**
* Extern oder intern besetzt.
*
* 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: von extern auf
* intern ist eine **Übernahme** und wird als eigenes Ereignis protokolliert
* (Migration 20260917120000).
*
* Nicht gesetzt heisst: dieses Formular fragt die Besetzungsart nicht. So
* ist es im Einstellungsassistenten — dort steht sie im Schritt „Position",
* wo sie hingehört, und zweimal danach zu fragen wäre eine Einladung, zwei
* verschiedene Antworten zu geben.
*/
source?: SourceType;
collectiveAgreement: CollectiveAgreement;
workDays: Weekday[];
isBetriebsrat: boolean;
@@ -30,28 +57,91 @@ export type RoleEmploymentValue = {
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
*/
kuendigungsschutzBis: string;
/**
* Der Personenkreis — freiwillig, aus demselben Grund wie das Enddatum:
* der Bestand trägt das Kennzeichen seit 20260814120000 ohne Grund, und ein
* Pflichtfeld hätte jede Änderung an diesen Zeilen blockiert.
*/
kuendigungsschutzGrund: string;
/** Beginn des Schutzes — ebenfalls freiwillig. */
kuendigungsschutzAb: string;
/**
* Begünstigte Behinderung. Eigenes Kennzeichen und nicht aus dem
* Personenkreis abgeleitet: die Begünstigung besteht unabhängig davon, ob
* sie als Kündigungsschutz geführt wird. Die Oberfläche stellt den
* Zusammenhang her, die Datenbank koppelt die beiden nicht.
*/
istBeguenstigtBehindert: boolean;
/** Grad in Prozent laut Bescheid. Als Text, weil ein leeres Zahlenfeld sonst 0 wäre. */
behinderungGrad: string;
behinderungAb: string;
behinderungBis: string;
};
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
// the same set of employees columns, just against different local state.
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
function toggleWorkDay(day: Weekday) {
onChange({ workDays: value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day] });
// Sortiert und nicht angehängt: sonst hinge die gespeicherte Reihenfolge
// davon ab, in welcher die Knöpfe angeklickt wurden. „Mo, Di" und „Di,
// Mo" waren damit zwei Werte für dieselbe Aussage, und wer die Tage nur
// nachsah und wieder herstellte, erzeugte eine Vertragsänderung in der
// Akte über nichts. Siehe lib/wochentage.ts.
const neu = value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day];
onChange({ workDays: sortiereWochentage(neu) });
}
// Der Personenkreis zieht das Kennzeichen mit: wer „Begünstigte behinderte
// ArbeitnehmerInnen" als Grund wählt, hat die Frage damit schon beantwortet.
// Eine Hilfe der Oberfläche, keine Bedingung der Datenbank — abwählen lässt
// es sich weiterhin.
const istBehindert = value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* „Beschäftigtengruppe" statt „Angestellte:r / Arbeiter:in": ein
Feld, das nach seinen Werten heisst, wird falsch, sobald ein
dritter dazukommt. Den Namen führt der Import schon länger. */}
<SelectField
label="Angestellte:r / Arbeiter:in"
label="Beschäftigtengruppe"
dense
value={value.workerType}
onChange={(v) => onChange({ workerType: v as WorkerType })}
options={[
{ value: "Angestellte:r", label: "Angestellte:r" },
{ value: "Arbeiter:in", label: "Arbeiter:in" },
{ value: "Lehrling", label: "Lehrling" },
]}
/>
{/* Zweites Feld und nicht dieselbe Liste erweitert: die Gruppe sagt,
*als was* jemand beschäftigt ist, die Art *in welcher Form*. Beides
gilt gleichzeitig — ein Praktikant ist Arbeiter oder Angestellter,
nicht statt dessen. */}
<SelectField
label="Mitarbeiterart"
dense
value={value.mitarbeiterart}
onChange={(v) => onChange({ mitarbeiterart: v as Mitarbeiterart })}
options={MITARBEITERARTEN.map((a) => ({ value: a, label: a }))}
/>
{/* Der Wechsel von extern auf intern ist eine Übernahme und wird als
eigenes Ereignis protokolliert — der Hinweis steht am Feld, damit
niemand ihn erst im Protokoll entdeckt. Der umgekehrte Weg
erzeugt nichts; das ist Absicht und keine Lücke. */}
{value.source !== undefined && (
<SelectField
label="Besetzungsart"
dense
value={value.source}
onChange={(v) => onChange({ source: v as SourceType })}
options={[
{ value: "Extern", label: "Extern" },
{ value: "Intern", label: "Intern" },
]}
hint={value.source === "Extern" ? "Ein Wechsel auf „Intern“ wird als Übernahme protokolliert." : undefined}
/>
)}
<SelectField
label="Kollektivvertrag"
dense
@@ -70,7 +160,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<fieldset>
<legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend>
<div className="flex flex-wrap gap-1.5">
{WEEKDAYS.map((day) => (
{WOCHENTAGE.map((day) => (
<button
key={day}
type="button"
@@ -130,6 +220,26 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
Weiteres", nicht „vergessen". */}
{value.hasKuendigungsschutz && (
<>
<SelectField
label="Personenkreis"
dense
value={value.kuendigungsschutzGrund}
onChange={(v) => onChange({ kuendigungsschutzGrund: v })}
options={[
{ value: "", label: "Nicht erfasst" },
...KUENDIGUNGSSCHUTZ_GRUENDE.map((g) => ({ value: g, label: g })),
]}
hint="Bestimmt, wer einer Beendigung zustimmen muss."
/>
<TextField
label="Geschützt ab"
dense
type="date"
value={value.kuendigungsschutzAb}
onChange={(v) => onChange({ kuendigungsschutzAb: v })}
hint="Optional."
/>
<TextField
label="Geschützt bis"
dense
@@ -138,6 +248,57 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
{/* ── Begünstigte Behinderung ────────────────────────────
Als Unterpunkt des Kündigungsschutzes und eingerückt — sie ist
einer der zwölf Personenkreise und stand vorher als eigener
Block daneben, was sie wie ein zweites, unabhängiges Thema
aussehen liess.
*
In der Datenbank bleibt sie trotzdem ein eigenes Kennzeichen
mit eigenen Feldern, und das mit Absicht: eine Bedingung
„Grad nur bei diesem Personenkreis" liesse jede Korrektur am
Personenkreis scheitern, solange der Grad noch dransteht —
also genau beim Geradebiegen eines Fehlers. Was hier
zusammengehört, muss dort nicht aneinandergekettet sein. */}
<div className="ml-1 flex flex-col gap-2 border-l-2 border-border-subtle pl-3">
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="checkbox"
checked={istBehindert}
onChange={(e) => onChange({ istBeguenstigtBehindert: e.target.checked })}
/>
Begünstigt behindert
</label>
{istBehindert && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField
label="Grad der Behinderung"
dense
type="number"
value={value.behinderungGrad}
onChange={(v) => onChange({ behinderungGrad: v })}
hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`}
/>
<TextField
label="Bescheid ab"
dense
type="date"
value={value.behinderungAb}
onChange={(v) => onChange({ behinderungAb: v })}
/>
<TextField
label="Bescheid bis"
dense
type="date"
value={value.behinderungBis}
onChange={(v) => onChange({ behinderungBis: v })}
hint="Leer heisst unbefristet."
/>
</div>
)}
</div>
</>
)}
</div>
</div>

View File

@@ -14,9 +14,11 @@ import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
import { STUNDEN_GRUENDE } from "@/lib/absence";
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
@@ -80,12 +82,25 @@ export function DatenAendernPanel({
const [role, setRole] = useState<RoleEmploymentValue>({
workerType: employee.worker_type ?? "Angestellte:r",
mitarbeiterart: employee.mitarbeiterart ?? MITARBEITERART_STANDARD,
// Hier gesetzt und im Assistenten nicht: dort steht die Besetzungsart im
// Schritt „Position". Ihr Wechsel von extern auf intern ist eine
// Übernahme und wird als eigenes Ereignis protokolliert.
source: employee.source ?? "Extern",
collectiveAgreement: employee.collective_agreement ?? "Handel",
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: employee.is_betriebsrat ?? false,
hasDienstwagen: employee.has_dienstwagen ?? false,
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
kuendigungsschutzGrund: employee.kuendigungsschutz_grund ?? "",
kuendigungsschutzAb: employee.kuendigungsschutz_ab ?? "",
istBeguenstigtBehindert: employee.ist_beguenstigt_behindert ?? false,
// Als Text und nicht als Zahl: ein leeres Zahlenfeld wäre 0, und 0
// bedeutet hier „kein Bescheid", nicht „Grad null".
behinderungGrad: employee.behinderung_grad === null ? "" : String(employee.behinderung_grad),
behinderungAb: employee.behinderung_ab ?? "",
behinderungBis: employee.behinderung_bis ?? "",
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
@@ -93,6 +108,10 @@ export function DatenAendernPanel({
function updateRole(patch: Partial<RoleEmploymentValue>) {
setRole((prev) => ({ ...prev, ...patch }));
}
// Dieselbe Regel wie in RoleEmploymentFields: der Personenkreis zieht das
// Kennzeichen mit. Hier noch einmal, weil nicht das Formular speichert,
// sondern diese Seite — und was gezeigt wird, muss auch abgeschickt werden.
const istBehindert = role.istBeguenstigtBehindert || role.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
function handleEmploymentTypeChange(value: EmploymentType) {
setEmploymentType(value);
@@ -159,13 +178,24 @@ export function DatenAendernPanel({
},
role: {
worker_type: role.workerType,
mitarbeiterart: role.mitarbeiterart,
source: role.source,
collective_agreement: role.collectiveAgreement,
work_days: role.workDays,
is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen,
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
has_kuendigungsschutz: role.hasKuendigungsschutz,
// Leer statt des Wertes, wenn das Kennzeichen weg ist: die Bedingung
// in der Datenbank duldet keinen Rest ohne Bezug, und die Funktion
// räumt ihn auf demselben Weg weg wie beim Dienstwagen.
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
kuendigungsschutz_grund: role.hasKuendigungsschutz ? role.kuendigungsschutzGrund : "",
kuendigungsschutz_ab: role.hasKuendigungsschutz ? role.kuendigungsschutzAb : "",
ist_beguenstigt_behindert: istBehindert,
behinderung_grad: istBehindert ? role.behinderungGrad : "",
behinderung_ab: istBehindert ? role.behinderungAb : "",
behinderung_bis: istBehindert ? role.behinderungBis : "",
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar

View File

@@ -10,16 +10,27 @@ import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
import { fmtDate, fmtName } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
import type { Database, EmploymentStatus } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Mode = "adjust" | "return";
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
export function KarenzPanel({
open,
onClose,
employee,
status,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
status: EmploymentStatus;
}) {
const { showToast } = useToast();
const router = useRouter();
const isOnKarenz = employee.status === "Karenz";
const isOnKarenz = status === "Karenz";
const [mode, setMode] = useState<Mode>("adjust");
const [pending, setPending] = useState(false);
@@ -188,7 +199,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
{mode === "adjust" && (
<>
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
<span className="font-semibold">{absenceLabel(employee.status, employee.absence_type)}</span>
<span className="font-semibold">{absenceLabel(status, employee.absence_type)}</span>
{" · Rückkehr "}
{fmtDate(employee.karenz_return_date)}
</p>

View File

@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database, PaygradeType } from "@/lib/supabase/types";
import type { Database, PaygradeType } from "@/lib/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];

View File

@@ -1,147 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { rehireEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, fmtName } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// Eine Wiedereinstellung braucht eine Planstelle — genau wie eine
// Neueinstellung.
//
// Die alte Stelle ist dafür kein Ersatz: sie kann inzwischen besetzt,
// ausgelaufen oder ganz entfallen sein. `rehire_employee` verlangte die
// Angabe deshalb schon immer und wies den Aufruf sonst ab; nur schickte das
// Formular sie nie mit, sodass jede Wiedereinstellung an einer Meldung
// scheiterte, die sich im Dialog gar nicht beheben liess.
export function RehirePanel({
open,
onClose,
employee,
openPositions,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
openPositions: OpenPositionResolved[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [rehireDate, setRehireDate] = useState("");
const [positionId, setPositionId] = useState("");
const [pending, setPending] = useState(false);
const options = useMemo(
() =>
openPositions
.slice()
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
[openPositions]
);
const selected = openPositions.find((p) => p.id === positionId);
// Die Datenbank weist eine Besetzung ausserhalb der Gültigkeit ab. Das hier
// nimmt die Meldung vorweg, solange sie noch etwas nützt — im Dialog, mit
// beiden Daten sichtbar.
const ausserhalb =
selected && rehireDate
? rehireDate < selected.valid_from
? `Diese Planstelle gilt erst ab ${fmtDate(selected.valid_from)}.`
: selected.valid_to && rehireDate >= selected.valid_to
? `Diese Planstelle gilt nur bis ${fmtDate(selected.valid_to)}.`
: null
: null;
async function handleSubmit() {
if (!rehireDate || !positionId) {
showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error");
return;
}
setPending(true);
const result = await rehireEmployee({
employee_id: employee.id,
rehire_date: rehireDate,
position_id: positionId,
});
setPending(false);
if (result.success) {
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<SlideOver
open={open}
onClose={onClose}
title="Wiedereinstellung"
subtitle={fmtName(employee.first_name, employee.last_name)}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending} disabled={Boolean(ausserhalb)}>
Wiedereinstellen
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<div className="rounded border border-border bg-surface p-3 text-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Letzte Position</p>
<p className="mt-1 text-ink">{employee.job_title}</p>
<p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
</div>
<TextField label="Wiedereintritt am" required type="date" value={rehireDate} onChange={setRehireDate} />
{options.length === 0 ? (
<p className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine
Planstelle anlegen oder eine bestehende freimachen.
</p>
) : (
<>
<SelectField
label="Planstelle"
required
value={positionId}
onChange={setPositionId}
placeholder="Bitte wählen…"
options={options}
/>
{selected && (
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
<div className="font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
<div className="mt-1 text-xs text-ink-muted">
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
{selected.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""}
{selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""}
</div>
</div>
)}
{ausserhalb && (
<p role="alert" className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
{ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen.
</p>
)}
</>
)}
</div>
</SlideOver>
);
}

View File

@@ -7,42 +7,37 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNG_NO_SHOW, BEENDIGUNGSART_WERTE } from "@/lib/beendigung";
import type { Austrittsart, Database, EmploymentStatus } from "@/lib/types";
import { fmtDate, fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
const NO_SHOW = "No Show";
const EXIT_REASONS = [
"Einvernehmliche Auflösung",
"Kündigung AN",
"Kündigung AG",
"Befristungsablauf",
"Pensionierung",
"Entlassung",
NO_SHOW,
];
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager
// braucht sie ebenso, und zwei Listen liefen auseinander.
const NO_SHOW = BEENDIGUNG_NO_SHOW;
type TerminatePanelProps = {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
status: EmploymentStatus;
directReportCount: number;
};
export function TerminatePanel({ open, onClose, employee, directReportCount }: TerminatePanelProps) {
export function TerminatePanel({ open, onClose, employee, status, directReportCount }: TerminatePanelProps) {
const { showToast } = useToast();
const router = useRouter();
const [exitDate, setExitDate] = useState("");
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
// übrigen zu verstellen.
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
const [reason, setReason] = useState(status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
// Leer als Vorbelegung: die Angabe ist freiwillig, und eine vorausgewählte
// Seite wäre eine Behauptung, die niemand getroffen hat.
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
const [note, setNote] = useState("");
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
const [pending, setPending] = useState(false);
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
@@ -62,6 +57,9 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
employee_id: employee.id,
exit_date: wirksamesDatum,
exit_reason: reason,
// Leer statt undefined: die SQL-Funktion macht daraus null, und null
// heisst „nicht erfasst" — das ist der ehrliche Wert.
austrittsart,
note,
});
setPending(false);
@@ -116,11 +114,38 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
</div>
)}
{/* ── Zwei Angaben, die einander nicht bestimmen ──────────────
Erst die Beendigungsart, darunter der Anstoss. Beide Listen sind
vollständig und keine schränkt die andere ein.
Der erste Entwurf hatte es umgekehrt: oben die Austrittsart, und
sie filterte die Beendigungsarten darunter, weil der Anstoss aus
der Art ableitbar schien. In Österreich ist er das nicht — die
einvernehmliche Auflösung ist der Regelfall und kann von beiden
Seiten ausgehen. Die Begründung steht in lib/beendigung.ts.
Unter der Beendigungsart stand ausserdem der abgeleitete Anstoss
als Hinweis. Er erschien von selbst und sah aus wie ein Fehler des
Formulars; er ist weg. */}
<SelectField
label="Beendigungsart"
value={reason}
onChange={setReason}
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
options={BEENDIGUNGSART_WERTE.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
/>
<SelectField
label="Freiwillig oder unfreiwillig"
value={austrittsart}
onChange={(v) => setAustrittsart((v || "") as Austrittsart | "")}
options={[
// „Nicht erfasst" als Vorbelegung und nicht eine der beiden
// Seiten: ein Befristungsablauf geschieht auf niemandes
// Betreiben, und ein Nichtantritt ist gar kein Austritt. Eine
// erzwungene Antwort wäre dort eine erfundene Zahl.
{ value: "", label: "Nicht erfasst" },
...AUSTRITTSARTEN.map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
]}
hint="Für die Fluktuationsauswertung. Lässt sich aus der Beendigungsart nicht ableiten."
/>
<TextField
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
@@ -142,22 +167,16 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
Stichtag als beschäftigt.
</p>
)}
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
<fieldset hidden={istNoShow}>
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
<div className="flex flex-col gap-2">
{CHECKLIST_ITEMS.map((item, i) => (
<label key={item} className="flex items-center gap-2 text-sm text-ink-body">
<input
type="checkbox"
checked={checked[i]}
onChange={(e) => setChecked((prev) => prev.map((c, idx) => (idx === i ? e.target.checked : c)))}
/>
{item}
</label>
))}
</div>
</fieldset>
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme —
deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen
echten Austritt legt terminateEmployee sie in derselben
Transaktion an; hier steht nur die Ankündigung, damit niemand sie
hier sucht und nichts findet. */}
{!istNoShow && (
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung.
</p>
)}
</div>
</SlideOver>
);

View File

@@ -8,7 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];

View File

@@ -0,0 +1,270 @@
"use client";
import { MessageSquarePlus, Printer } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import {
alleReichungen,
fortschrittVon,
istErledigt,
type AufgabenGruppe,
type AufgabenPunkt,
type AufgabenStand,
} from "@/lib/checklist";
import { fmtDate } from "@/lib/format";
import type { ActionResult } from "@/lib/db/rpc";
// Die Checkliste, die bisher ein Blatt Papier war — geteilt zwischen
// Onboarding und Offboarding. Beide sind dieselbe Sache mit anderen Punkten:
// eine Liste, die sich abhaken, mit Ja/Nein beantworten oder mit einem Wert
// füllen lässt, mit Kommentar, und mit einem Vermerk, **wer** wann etwas
// eingetragen hat — auf dem Blatt stand nur ein Haken.
//
// Gespeichert wird beim Klick, nicht beim Absenden. Eine Checkliste wird über
// Tage abgearbeitet, oft zwischen zwei anderen Dingen; ein „Speichern"-Knopf
// am Ende wäre die Stelle, an der ein halber Vormittag verlorengeht.
type Props = {
gruppen: readonly AufgabenGruppe[];
staende: AufgabenStand[];
/** Ob überhaupt eine Liste existiert. */
vorhanden: boolean;
/** Text, solange keine Liste existiert. */
leerText: string;
anlegenLabel: string;
/** Ziel für den Ausdruck — eine eigene Seite, siehe PrintChecklist. */
druckHref: string;
onSpeichern: (
itemKey: string,
teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }
) => Promise<ActionResult>;
onAnlegen: () => Promise<ActionResult>;
};
export function ChecklistPanel({
gruppen,
staende,
vorhanden,
leerText,
anlegenLabel,
druckHref,
onSpeichern,
onAnlegen,
}: Props) {
const { showToast } = useToast();
const router = useRouter();
const [laeuft, setLaeuft] = useState<string | null>(null);
const [kommentarOffen, setKommentarOffen] = useState<Set<string>>(new Set());
const punkte = alleReichungen(gruppen);
const karte = new Map(staende.map((s) => [s.item_key, s]));
const stand = fortschrittVon(punkte, staende);
async function speichern(key: string, teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) {
setLaeuft(key);
const ergebnis = await onSpeichern(key, teil);
setLaeuft(null);
if (ergebnis.success) router.refresh();
else showToast(ergebnis.error ?? "Konnte nicht gespeichert werden.", "error");
}
async function listeAnlegen() {
setLaeuft("neu");
const ergebnis = await onAnlegen();
setLaeuft(null);
if (ergebnis.success) {
showToast("Checkliste angelegt.");
router.refresh();
} else {
showToast(ergebnis.error ?? "Konnte nicht angelegt werden.", "error");
}
}
if (!vorhanden) {
return (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-ink-body">{leerText}</p>
<Button onClick={listeAnlegen} pending={laeuft === "neu"}>
{anlegenLabel}
</Button>
</div>
);
}
const anteil = Math.round((stand.erledigt / stand.gesamt) * 100);
return (
<div className="flex flex-col gap-6">
<div className="flex justify-end">
{/* Neuer Tab: der Ausdruck ist eine eigene Seite, und wer ihn schliesst,
steht wieder in der Akte statt auf einer leeren Historie. */}
<Link href={druckHref} target="_blank" className={LINK_BUTTON_CLASS}>
<Printer className="h-4 w-4" />
Als PDF speichern
</Link>
</div>
<div className="rounded border border-border bg-surface px-3 py-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-ink">
{stand.erledigt} von {stand.gesamt} erledigt
</span>
<span className="text-xs text-ink-muted">
{stand.offen.length === 0 ? "Vollständig." : `Offen: ${stand.offen.slice(0, 3).join(", ")}`}
{stand.offen.length > 3 ? ` und ${stand.offen.length - 3} weitere` : ""}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white">
<div
className={`h-full rounded-full ${anteil === 100 ? "bg-success-text" : "bg-brand-500"}`}
style={{ width: `${Math.max(2, anteil)}%` }}
/>
</div>
</div>
{gruppen.map((gruppe) => (
<div key={gruppe.key}>
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">{gruppe.label}</h3>
<ul className="mt-2 flex flex-col divide-y divide-border-subtle">
{gruppe.punkte.map((punkt) => (
<Zeile
key={punkt.key}
punkt={punkt}
stand={karte.get(punkt.key)}
laeuft={laeuft === punkt.key}
kommentarOffen={kommentarOffen.has(punkt.key)}
onKommentarOeffnen={() =>
setKommentarOffen((prev) => {
const next = new Set(prev);
next.add(punkt.key);
return next;
})
}
onSpeichern={(teil) => speichern(punkt.key, teil)}
/>
))}
</ul>
</div>
))}
</div>
);
}
function Zeile({
punkt,
stand,
laeuft,
kommentarOffen,
onKommentarOeffnen,
onSpeichern,
}: {
punkt: AufgabenPunkt;
stand: AufgabenStand | undefined;
laeuft: boolean;
kommentarOffen: boolean;
onKommentarOeffnen: () => void;
onSpeichern: (teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) => void;
}) {
const erledigt = istErledigt(punkt, stand);
const [text, setText] = useState(stand?.wert ?? "");
const [kommentar, setKommentar] = useState(stand?.kommentar ?? "");
const zeigeKommentar = kommentarOffen || Boolean(stand?.kommentar);
return (
<li className="py-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<div className="min-w-0 flex-1">
<span className={`text-sm ${erledigt ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}`}>
{punkt.label}
</span>
{punkt.hinweis && <span className="block text-xs text-ink-muted">{punkt.hinweis}</span>}
</div>
{punkt.art === "haken" && (
<input
type="checkbox"
aria-label={punkt.label}
checked={stand?.erledigt ?? false}
disabled={laeuft}
onChange={(e) => onSpeichern({ erledigt: e.target.checked })}
className="h-4 w-4 shrink-0 rounded border-border text-brand-600
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
/>
)}
{punkt.art === "janein" && (
// Zwei Knöpfe statt eines Hakens: „nein" ist ein erhobener Befund,
// „noch nicht gefragt" nicht. Ein Haken könnte das nicht sagen.
<span className="flex shrink-0 gap-1">
{(["ja", "nein"] as const).map((wert) => (
<button
key={wert}
type="button"
disabled={laeuft}
aria-pressed={stand?.wert === wert}
onClick={() => onSpeichern({ wert: stand?.wert === wert ? null : wert })}
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
stand?.wert === wert ? "bg-brand-500 text-white" : "bg-surface text-ink-muted hover:text-ink"
}`}
>
{wert === "ja" ? "Ja" : "Nein"}
</button>
))}
</span>
)}
{punkt.art === "text" && (
<input
type="text"
aria-label={`${punkt.label} — Wert`}
value={text}
disabled={laeuft}
onChange={(e) => setText(e.target.value)}
onBlur={() => text !== (stand?.wert ?? "") && onSpeichern({ wert: text || null })}
placeholder="Wert"
className="w-28 shrink-0 rounded border border-border px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
)}
{!zeigeKommentar && (
<button
type="button"
onClick={onKommentarOeffnen}
aria-label={`Kommentar zu ${punkt.label}`}
className="shrink-0 rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
)}
</div>
{zeigeKommentar && (
<input
type="text"
aria-label={`Kommentar zu ${punkt.label}`}
value={kommentar}
disabled={laeuft}
onChange={(e) => setKommentar(e.target.value)}
onBlur={() => kommentar !== (stand?.kommentar ?? "") && onSpeichern({ kommentar: kommentar || null })}
placeholder="Kommentar …"
className="mt-1.5 w-full rounded border border-border bg-surface px-2 py-1 text-xs text-ink-body
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
)}
{/* Wer und wann — der Unterschied zum Blatt Papier, auf dem nur der
Haken stand. */}
{stand?.updated_by_name && (erledigt || stand.kommentar) && (
<p className="mt-1 text-[11px] text-ink-muted">
{stand.updated_by_name}
{stand.updated_at ? ` · ${fmtDate(stand.updated_at)}` : ""}
</p>
)}
</li>
);
}

View File

@@ -6,10 +6,10 @@ import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { TextField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { actionBadgeStyle } from "@/lib/colors";
import { eventBadgeStyle } from "@/lib/colors";
import { fmtDate, todayIso } from "@/lib/format";
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
@@ -108,7 +108,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
onClick={() => typUmschalten(typ)}
aria-pressed={typen.has(typ)}
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
typen.has(typ) ? eventBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
}`}
>
{typ}
@@ -145,7 +145,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${eventBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>

View File

@@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { useToast } from "@/components/ui/Toast";
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { Database, NoteCategory } from "@/lib/supabase/types";
import type { Database, NoteCategory } from "@/lib/types";
type Note = Database["public"]["Tables"]["employee_notes"]["Row"];
@@ -103,7 +103,7 @@ export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: N
{n.done ? (
<span className="rounded-full bg-success-bg px-2 py-0.5 text-xs font-semibold text-success-text">Erledigt</span>
) : (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
<span className="rounded-full bg-accent-200 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
)}
</div>
<p className="mt-1 text-sm text-ink">{n.note_text}</p>

View File

@@ -0,0 +1,33 @@
"use client";
import { setOffboardingTask, startOffboarding } from "@/actions/employees";
import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding";
import type { AufgabenStand } from "@/lib/checklist";
import { ChecklistPanel } from "./ChecklistPanel";
// Dünne Bindung — siehe OnboardingTab.tsx, dieselbe Mechanik für den anderen
// Weg.
export function OffboardingTab({
employeeId,
staende,
vorhanden,
}: {
employeeId: string;
staende: AufgabenStand[];
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Austritt. */
vorhanden: boolean;
}) {
return (
<ChecklistPanel
gruppen={OFFBOARDING_GRUPPEN}
staende={staende}
vorhanden={vorhanden}
leerText="Für diese Person gibt es keine Offboarding-Checkliste. Für einen No Show wird keine angelegt — wer nie angetreten ist, hat nichts offzuboarden."
anlegenLabel="Checkliste anlegen"
druckHref={`/employees/${employeeId}/checkliste?art=offboarding`}
onSpeichern={(itemKey, teil) => setOffboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
onAnlegen={() => startOffboarding(employeeId)}
/>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { setOnboardingTask, startOnboarding } from "@/actions/employees";
import { ONBOARDING_GRUPPEN } from "@/lib/onboarding";
import type { AufgabenStand } from "@/lib/checklist";
import { ChecklistPanel } from "./ChecklistPanel";
// Dünne Bindung: die Mechanik der Checkliste steht in ChecklistPanel, geteilt
// mit OffboardingTab. Hier steht nur, welches Verzeichnis gilt und welche
// Server-Aktionen es speichern.
export function OnboardingTab({
employeeId,
staende,
vorhanden,
}: {
employeeId: string;
staende: AufgabenStand[];
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Eintritt. */
vorhanden: boolean;
}) {
return (
<ChecklistPanel
gruppen={ONBOARDING_GRUPPEN}
staende={staende}
vorhanden={vorhanden}
leerText="Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder Wiedereinstellung — wer davor eingetreten ist, hat keine."
anlegenLabel="Checkliste anlegen"
druckHref={`/employees/${employeeId}/checkliste?art=onboarding`}
onSpeichern={(itemKey, teil) => setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
onAnlegen={() => startOnboarding(employeeId)}
/>
);
}

View File

@@ -21,6 +21,15 @@ type OrganisationTabProps = {
kostenstelle: { code: string; name: string } | null;
};
// Die Niederlassung stand hier kurzzeitig neben Einheit und Kostenstelle —
// unsere Auslegung von „Niederlassungen auch in Zuordnung einfügen"
// (Anforderung 8). Im Gespräch am 17.09.2026 hat der Kunde sie hier wieder
// gestrichen: „nimm's mal hier raus, ich glaub, da brauchen wir's nicht
// drin." Sie steht weiterhin im Vertragsblatt.
//
// Was mit der Anforderung gemeint war, ist damit weiterhin offen — siehe
// docs/rueckfragen-workshop-2026-09.md.
export function OrganisationTab({
employeeId,
manager,

View File

@@ -1,7 +1,7 @@
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { brauchtAufenthaltstitel } from "@/lib/countries";
import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];

View File

@@ -1,6 +1,6 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
import type { Database } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -27,7 +27,7 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["Wochenstunden", `${employee.weekly_hours} h`],
["Urlaubsanspruch", "25 Tage"],
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
["Beschäftigtengruppe", employee.worker_type ?? "–"],
["Arbeitstage", employee.work_days?.join(", ") || "–"],
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
// war die Frage dahinter, seit E-Fahrzeuge getrennt zu führen sind.

View File

@@ -1,12 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
import type { OpenPositionResolved } from "@/lib/positions";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { StepAngehoerige } from "./StepAngehoerige";
@@ -17,7 +18,12 @@ import { StepSummary } from "./StepSummary";
import { StepVertrag } from "./StepVertrag";
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
// Vertrag vor Angehörige: was den Vertrag ausmacht — Eintritt, Arbeitstage,
// Befristung — steht auf dem Papier, das vor dem Gespräch da ist. Angehörige
// bringt die Person mit, oft erst am ersten Tag. Bis September 2026 stand es
// andersherum, und der freiwillige Schritt lag vor dem, der die Einstellung
// überhaupt trägt.
const STEP_LABELS = ["Person", "Position", "Vertrag", "Angehörige", "Notfallkontakt", "Zusammenfassung"] as const;
type HireWizardProps = {
open: boolean;
@@ -54,36 +60,62 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
// Blocks step 1 rather than letting the hire fail at the RPC: the SVNR
// trigger rejects a bad number, and by then the user is three steps on.
// Dieselbe Überlegung wie bei der SV-Nummer, nur dass die Antwort aus der
// Datenbank kommt: eine vergebene Personalnummer wies bisher erst
// hire_employee ab — nach sechs Schritten Eingabe, und die Nummer steht im
// ersten Feld des ersten Schritts.
//
// Gemerkt wird die *Nummer*, die als vergeben zurückkam, nicht ein Ja/Nein.
// So ist die Sperre eine Ableitung aus dem, was im Feld steht, und muss
// beim Weitertippen nicht zurückgesetzt werden — ein Zurücksetzen, das man
// vergessen kann, sperrt sonst ein Formular ohne sichtbaren Grund.
const [vergebeneNummer, setVergebeneNummer] = useState<number | null>(null);
const nummerFrei = Number(draft.personnelNumber) !== vergebeneNummer;
const merkeBefund = useCallback(
(befund: { nummer: number; vergeben: boolean }) => setVergebeneNummer(befund.vergeben ? befund.nummer : null),
[]
);
const svNummerOk =
!draft.svNummer.trim() ||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
isValidSvnr(draft.svNummer, draft.birthDate || null);
const stepValid = [
// Am Namen des Schritts, nicht an seiner Nummer.
//
// Vorher war das eine Liste in derselben Reihenfolge wie STEP_LABELS, und
// die beiden mussten stumm zusammenpassen. Beim Vertauschen von Vertrag und
// Angehörige wäre die Prüfung stehengeblieben, wo sie war: „Weiter" im
// Vertrag hätte die Angehörigen geprüft und ein leeres Eintrittsdatum
// durchgelassen — bis die Datenbank es am Ende abweist.
const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = {
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
// Schritts, nach allen Eingaben.
// die Prüfung hier bricht erst die Datenbank ab — nach allen Eingaben.
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
// freiwillig, seit die Spalte NULL zulässt.
Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
svNummerOk,
Boolean(draft.positionId && draft.besetzung),
Person:
Boolean(
draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId
) && svNummerOk && nummerFrei,
Position: Boolean(draft.positionId && draft.besetzung),
Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0),
// Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig
// sein, sonst scheitert sie erst nach dem Anlegen der Person, und die
// steht dann schon in der Datenbank.
draft.angehoerige.every(
Angehörige: draft.angehoerige.every(
(a) =>
a.firstName.trim() &&
a.lastName.trim() &&
a.birthDate &&
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
),
Boolean(draft.entryDate && draft.workDays.length > 0),
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
Notfallkontakt:
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
true,
][step];
Zusammenfassung: true,
};
const stepValid = pruefung[STEP_LABELS[step]];
/** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */
const letzterSchritt = STEP_LABELS.length - 1;
@@ -101,6 +133,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
async function handleSubmit() {
if (!selectedPosition || !draft.besetzung) return;
// Dieselbe Regel wie im Formular (RoleEmploymentFields): der
// Personenkreis zieht das Kennzeichen mit. Was gezeigt wurde, muss auch
// abgeschickt werden.
const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
setSubmitting(true);
const result = await hireEmployee({
personnel_number: Number(draft.personnelNumber),
@@ -130,9 +166,17 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
// sonst nicht zu.
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
mitarbeiterart: draft.mitarbeiterart,
has_kuendigungsschutz: draft.hasKuendigungsschutz,
// Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu.
// Ohne Schutz kein Enddatum, kein Beginn, kein Personenkreis — die
// CHECKs lassen es nicht anders zu.
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
kuendigungsschutz_grund: draft.hasKuendigungsschutz ? draft.kuendigungsschutzGrund || null : null,
kuendigungsschutz_ab: draft.hasKuendigungsschutz ? draft.kuendigungsschutzAb || null : null,
ist_beguenstigt_behindert: istBehindert,
behinderung_grad: istBehindert ? draft.behinderungGrad || null : null,
behinderung_ab: istBehindert ? draft.behinderungAb || null : null,
behinderung_bis: istBehindert ? draft.behinderungBis || null : null,
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
@@ -240,10 +284,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
))}
</div>
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} onNummerBefund={merkeBefund} />}
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
{step === 2 && <StepAngehoerige draft={draft} update={update} />}
{step === 3 && <StepVertrag draft={draft} update={update} />}
{step === 2 && <StepVertrag draft={draft} update={update} />}
{step === 3 && <StepAngehoerige draft={draft} update={update} />}
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
</Modal>

View File

@@ -1,11 +1,14 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { entwurfFreigeben, entwurfSperren } from "@/actions/hireDrafts";
import { useToast } from "@/components/ui/Toast";
import type { Entwurf } from "@/lib/entwuerfe";
import type { OpenPositionResolved } from "@/lib/positions";
import { HireWizard } from "./HireWizard";
type Location = { id: string; name: string; country: string };
type HireDraft = { id: string; step: number; payload: Record<string, unknown> };
type OpenWizardOptions = { draftId?: string; positionId?: string };
@@ -15,6 +18,16 @@ type HireWizardContextValue = {
const HireWizardContext = createContext<HireWizardContextValue | null>(null);
/**
* Wie oft die Sperre aufgefrischt wird, solange der Assistent offen ist.
*
* Deutlich kürzer als die Frist in der Datenbank (15 Minuten,
* app_entwurf_sperrfrist): zwischen zwei Takten darf eine Antwort ausfallen,
* ohne dass die Sperre wegläuft. Wer eine halbe Stunde an einem Entwurf
* sitzt, soll ihn nicht auf halbem Weg an eine Kollegin verlieren.
*/
const TAKT_MS = 5 * 60 * 1000;
export function HireWizardProvider({
children,
openPositions,
@@ -24,10 +37,12 @@ export function HireWizardProvider({
children: ReactNode;
openPositions: OpenPositionResolved[];
locations: Location[];
drafts: HireDraft[];
drafts: Entwurf[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [open, setOpen] = useState(false);
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
const [resumeDraft, setResumeDraft] = useState<Entwurf | null>(null);
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
// Forces HireWizard to remount fresh each time it's opened, so its
// internal draft/step state is (re-)initialized directly from the current
@@ -35,20 +50,73 @@ export function HireWizardProvider({
// effect needed inside HireWizard itself.
const [openKey, setOpenKey] = useState(0);
function openWizard(options?: OpenWizardOptions) {
// Die Kennung des Entwurfs, dessen Sperre wir halten. Als Ref, weil sie im
// Aufräumen des Effekts gebraucht wird — über den Zustand gelesen wäre es
// dort der Stand von vorhin.
const gesperrt = useRef<string | null>(null);
async function openWizard(options?: OpenWizardOptions) {
// Ohne Entwurf gibt es nichts zu sperren: eine neue Einstellung entsteht
// erst beim Speichern.
if (options?.draftId) {
const result = await entwurfSperren(options.draftId);
if (!result.success) {
showToast(result.error ?? "Der Entwurf lässt sich gerade nicht öffnen.", "error");
// Die Liste holt sich den Stand: wer die Sperre hält, steht danach
// an der Zeile, statt dass nur eine Meldung aufblitzt.
router.refresh();
return;
}
gesperrt.current = options.draftId;
}
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
setInitialPositionId(options?.positionId);
setOpen(true);
setOpenKey((k) => k + 1);
}
function closeWizard() {
setOpen(false);
freigeben();
}
function freigeben() {
const id = gesperrt.current;
if (!id) return;
gesperrt.current = null;
void entwurfFreigeben(id);
}
// Auffrischen, solange der Assistent offen ist. Ohne das liefe die Frist
// einem langen Ausfüllen davon, und das Speichern am Ende fände seine
// eigene Sperre abgelaufen.
//
// `!open` ist doppelt gemoppelt: freigeben() räumt beim Zumachen auch
// gesperrt.current weg, und daran scheitert der Takt ohnehin. Es steht
// trotzdem da, weil es die Bedingung ausspricht, um die es geht — dass
// nach dem Zumachen nichts mehr aufgefrischt wird, hängt sonst an einer
// Zuweisung drei Funktionen weiter oben.
useEffect(() => {
if (!open || !gesperrt.current) return;
const timer = setInterval(() => {
if (gesperrt.current) void entwurfSperren(gesperrt.current);
}, TAKT_MS);
return () => clearInterval(timer);
}, [open, openKey]);
// Beim Verlassen der Seite: dieselbe Freigabe wie beim Schliessen. Sie
// erreicht den Server nicht immer — ein zugeklappter Laptop schickt nichts
// mehr. Deshalb ist sie die Höflichkeit und nicht die Absicherung; die
// Absicherung ist die Frist in der Datenbank.
useEffect(() => () => freigeben(), []);
return (
<HireWizardContext.Provider value={{ openWizard }}>
{children}
<HireWizard
key={openKey}
open={open}
onClose={() => setOpen(false)}
onClose={closeWizard}
openPositions={openPositions}
locations={locations}
resumeDraft={resumeDraft}

View File

@@ -0,0 +1,99 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { personalnummerVergeben } from "@/actions/employees";
import { TextField } from "@/components/ui/Field";
// Die Personalnummer wird eingegeben, nicht vergeben — sie muss mit Loga und
// Interflex übereinstimmen. Genau deshalb kommt sie von aussen, und genau
// deshalb kann sie schon vergeben sein: an dieselbe Person, die jemand ein
// zweites Mal anlegt, oder an eine ganz andere nach einem Zahlendreher.
//
// Die Datenbank weist das ab (hire_employee prüft es eigens), aber erst beim
// Anlegen — nach sechs Schritten Eingabe. Bis dahin ist die falsche Nummer
// schon durch alle Formulare gereist.
//
// ── Wann gefragt wird ───────────────────────────────────────────────
//
// Nicht bei jedem Tastendruck: „3", „34", „347" wären drei Fragen an die
// Datenbank für eine Eingabe, und die ersten beiden hätten eine Antwort auf
// eine Nummer, die niemand meint. Stattdessen eine halbe Sekunde Ruhe
// abwarten — lange genug, dass eine vierstellige Nummer am Stück eingetippt
// eine einzige Frage ergibt, kurz genug, dass die Antwort da ist, bevor der
// Blick zum nächsten Feld wandert.
//
// Die Antwort kann veralten, während sie unterwegs ist. Deshalb zählt jede
// Anfrage mit und nur die jüngste darf schreiben: sonst überschriebe die
// langsamere Antwort auf „347" die schnellere auf „3471".
//
// ── Warum der Befund an der Nummer hängt und nicht an einem Ja/Nein ──
//
// Gespeichert wird **die geprüfte Nummer** mitsamt ihrem Ergebnis, nicht
// bloss „frei" oder „vergeben". Damit ist jede Anzeige eine Ableitung aus
// dem, was gerade im Feld steht — es gibt keinen Zustand, der zurückgesetzt
// werden müsste, wenn die Eingabe sich ändert, und deshalb auch keine Stelle,
// an der das Zurücksetzen vergessen werden kann.
const WARTEZEIT_MS = 500;
type Befund = { nummer: number; vergeben: boolean; name?: string };
export function PersonalnummerField({
wert,
onChange,
onBefund,
}: {
wert: string;
onChange: (wert: string) => void;
/** Meldet dem Assistenten die geprüfte Nummer und ihr Ergebnis. */
onBefund: (befund: Befund) => void;
}) {
const [befund, setBefund] = useState<Befund | null>(null);
const laufendeNr = useRef(0);
const nummer = Number(wert);
const gueltig = Boolean(wert.trim()) && Number.isInteger(nummer) && nummer > 0;
// Nur ein Befund zu *dieser* Nummer zählt. Steht im Feld inzwischen etwas
// anderes, ist die alte Auskunft gegenstandslos.
const passend = gueltig && befund?.nummer === nummer ? befund : null;
const laeuft = gueltig && !passend;
useEffect(() => {
if (!gueltig) return;
const meine = ++laufendeNr.current;
const zeit = setTimeout(async () => {
let antwort: { vergeben: boolean; name?: string };
try {
antwort = await personalnummerVergeben(nummer);
} catch {
// Keine Auskunft ist kein Hindernis: die Datenbank entscheidet beim
// Anlegen ohnehin. Eine Meldung über eine gescheiterte *Vorab*-Prüfung
// wäre für die Eingebende nur Lärm.
antwort = { vergeben: false };
}
if (meine !== laufendeNr.current) return;
const neu: Befund = { nummer, ...antwort };
setBefund(neu);
onBefund(neu);
}, WARTEZEIT_MS);
return () => clearTimeout(zeit);
}, [gueltig, nummer, onBefund]);
return (
<TextField
label="Personalnummer"
required
inputMode="numeric"
value={wert}
onChange={(v) => onChange(v.replace(/\D/g, ""))}
error={
passend?.vergeben
? `Diese Personalnummer ist bereits vergeben${passend.name ? ` — an ${passend.name}` : ""}.`
: undefined
}
hint={laeuft ? "Wird geprüft…" : "Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."}
/>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { rehireEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtName } from "@/lib/format";
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
import type { OpenPositionResolved } from "@/lib/positions";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import type { Database } from "@/lib/types";
import { vorbelegungAus } from "@/lib/wiedereintritt";
import { StepNotfallkontakt } from "./StepNotfallkontakt";
import { StepPerson } from "./StepPerson";
import { StepPosition } from "./StepPosition";
import { StepSummary } from "./StepSummary";
import { StepVertrag } from "./StepVertrag";
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// Der Wiedereintritt als Assistent — derselbe Weg wie eine Neueinstellung,
// nur vorbefüllt.
//
// ── Warum nicht der kleine Dialog von vorher ────────────────────────
//
// Der fragte Datum und Planstelle und liess alles andere stehen, wie es beim
// Austritt war. Nach zwei Jahren Abwesenheit ist das selten noch richtig: die
// Anschrift, die Wochenstunden, der Kollektivvertrag, oft auch der Name. Wer
// das bemerkte, musste die Person erst wiedereinstellen und danach „Daten
// ändern" öffnen — zwei Vorgänge für einen, und in der Akte steht dann eine
// Vertragsänderung am Tag des Wiedereintritts, die niemand vorgenommen hat.
//
// ── Warum ein eigenes Bauteil und kein Schalter im HireWizard ───────
//
// Die beiden Abläufe sehen gleich aus und sind es nicht: hier gibt es keinen
// Entwurf zu speichern, keine Angehörigen anzulegen (die stehen schon in der
// Akte), keine Personalnummer zu prüfen, und am Ende ruft er eine andere
// Funktion. Ein HireWizard mit `modus === "rehire"` an sechs Stellen wäre
// schwerer zu lesen als zwei Abläufe, die sich die Schritte teilen — und die
// Schritte sind der Teil, der wirklich geteilt gehört.
//
// ── Warum alles in einem Aufruf geht ────────────────────────────────
//
// `rehire_employee` nimmt den ganzen Satz entgegen und schreibt ihn in einer
// Transaktion (Migration 20260917130000). Erst wiedereinstellen und dann
// ändern wären zwei Transaktionen, und scheitert die zweite, steht die Person
// wieder im Dienst — mit den Daten von damals und ohne dass es jemand merkt.
const STEP_LABELS = ["Person", "Position", "Vertrag", "Notfallkontakt", "Zusammenfassung"] as const;
type RehireWizardProps = {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
openPositions: OpenPositionResolved[];
locations: { id: string; name: string; country: string }[];
};
export function RehireWizard({ open, onClose, employee, openPositions, locations }: RehireWizardProps) {
const { showToast } = useToast();
const router = useRouter();
const [step, setStep] = useState(0);
// Die Akte remountet dieses Bauteil über einen wechselnden `key`, sobald es
// frisch geöffnet wird — diese Initialisierung ist damit das Zurücksetzen,
// ganz ohne Effekt.
const [draft, setDraft] = useState<HireDraftData>(() => ({
...EMPTY_HIRE_DRAFT,
...vorbelegungAus(employee),
// Planstelle und Eintrittsdatum bleiben leer: die alte Stelle kann
// besetzt oder entfallen sein, und das neue Datum ist die eigentliche
// Angabe dieses Vorgangs. Siehe lib/wiedereintritt.ts.
positionId: "",
entryDate: "",
angehoerige: [],
}));
const [submitting, setSubmitting] = useState(false);
const selectedPosition = useMemo(
() => openPositions.find((p) => p.id === draft.positionId) ?? null,
[openPositions, draft.positionId]
);
function update(patch: Partial<HireDraftData>) {
setDraft((prev) => ({ ...prev, ...patch }));
}
const svNummerOk =
!draft.svNummer.trim() ||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
isValidSvnr(draft.svNummer, draft.birthDate || null);
// Dieselben Bedingungen wie bei einer Neueinstellung, abzüglich der
// Personalnummer: sie steht fest und wird nicht geprüft.
const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = {
Person: Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
Position: Boolean(draft.positionId && draft.besetzung),
Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0),
Notfallkontakt: Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
Zusammenfassung: true,
};
const stepValid = pruefung[STEP_LABELS[step]];
const letzterSchritt = STEP_LABELS.length - 1;
async function handleSubmit() {
if (!draft.positionId || !draft.entryDate) return;
// Dieselbe Regel wie im Formular: der Personenkreis zieht das Kennzeichen
// mit. Was gezeigt wurde, muss auch abgeschickt werden.
const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
setSubmitting(true);
const result = await rehireEmployee({
employee_id: employee.id,
rehire_date: draft.entryDate,
position_id: draft.positionId,
first_name: draft.firstName,
last_name: draft.lastName,
title_prefix: draft.titlePrefix,
title_suffix: draft.titleSuffix,
gender: draft.gender,
birth_date: draft.birthDate,
sv_nummer: draft.svNummer.trim(),
email: draft.email.trim(),
phone: draft.phone.trim(),
location_id: draft.locationId,
// Anschrift, Staatsbürgerschaft und Aufenthaltstitel stehen bewusst
// nicht hier: der Assistent zeigt sie nicht, und was er nicht zeigt,
// darf er nicht schicken. rehire_employee lässt jedes nicht
// übermittelte Feld unangetastet stehen.
emergency_contact_name: draft.emergencyContactName.trim(),
emergency_contact_phone: draft.emergencyContactPhone.trim(),
emergency_contact_relation: draft.emergencyContactRelation.trim(),
employment_type: draft.employmentType,
weekly_hours: Number(draft.weeklyHours),
contract_type: draft.contractType,
contract_end_date: draft.contractEndDate,
paygrade: draft.paygrade,
source: draft.besetzung,
worker_type: draft.workerType,
mitarbeiterart: draft.mitarbeiterart,
collective_agreement: draft.collectiveAgreement,
work_days: draft.workDays,
is_betriebsrat: draft.isBetriebsrat,
has_dienstwagen: draft.hasDienstwagen,
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : "",
is_laterale_fuehrung: draft.isLateraleFuehrung,
is_c_level: draft.isCLevel,
has_kuendigungsschutz: draft.hasKuendigungsschutz,
kuendigungsschutz_grund: draft.hasKuendigungsschutz ? draft.kuendigungsschutzGrund : "",
kuendigungsschutz_ab: draft.hasKuendigungsschutz ? draft.kuendigungsschutzAb : "",
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis : "",
ist_beguenstigt_behindert: istBehindert,
behinderung_grad: istBehindert ? draft.behinderungGrad : "",
behinderung_ab: istBehindert ? draft.behinderungAb : "",
behinderung_bis: istBehindert ? draft.behinderungBis : "",
});
setSubmitting(false);
if (!result.success) {
showToast(result.error ?? "Fehler beim Speichern.", "error");
return;
}
router.refresh();
onClose();
showToast(`${fmtName(draft.firstName, draft.lastName)} wurde wiedereingestellt.`);
}
return (
<Modal
open={open}
onClose={onClose}
title="Wiedereintritt"
widthClassName="max-w-2xl"
footer={
<div className="flex w-full items-center justify-between">
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<div className="flex gap-2">
{step > 0 && (
<Button variant="secondary" onClick={() => setStep((s) => s - 1)}>
Zurück
</Button>
)}
{step < letzterSchritt && (
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
Weiter
</Button>
)}
{step === letzterSchritt && (
<Button onClick={handleSubmit} pending={submitting}>
Wiedereinstellen
</Button>
)}
</div>
</div>
}
>
<p className="mb-4 rounded bg-surface px-3 py-2 text-sm text-ink-body">
Die Angaben stammen aus der bestehenden Akte und lassen sich hier ändern. Planstelle und Eintrittsdatum sind neu
zu wählen; Angehörige stehen bereits in der Akte.
</p>
<div className="mb-6 flex items-center justify-center gap-3">
{STEP_LABELS.map((label, i) => (
<button
key={label}
type="button"
disabled={i >= step}
aria-current={i === step ? "step" : undefined}
onClick={() => i < step && setStep(i)}
className={`flex items-center gap-2 rounded text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:cursor-default ${
i === step ? "text-brand-700" : i < step ? "text-ink-body" : "text-ink-muted"
}`}
>
<span
className={`flex h-6 w-6 items-center justify-center rounded-full ${i <= step ? "bg-brand-500 text-white" : "bg-surface text-ink-muted"}`}
>
{i + 1}
</span>
{label}
</button>
))}
</div>
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} nummerGesperrt />}
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
{step === 2 && <StepVertrag draft={draft} update={update} />}
{step === 3 && <StepNotfallkontakt draft={draft} update={update} />}
{step === 4 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
</Modal>
);
}

View File

@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { fmtDate } from "@/lib/format";
import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
import type { RelationshipType } from "@/lib/supabase/types";
import type { RelationshipType } from "@/lib/types";
import type { HireDraftAngehoerige, HireDraftData } from "./types";
const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];

View File

@@ -1,5 +1,5 @@
import { SelectField, TextField } from "@/components/ui/Field";
import { EMERGENCY_RELATIONS } from "@/lib/supabase/types";
import { EMERGENCY_RELATIONS } from "@/lib/types";
import type { HireDraftData } from "./types";
// Eigener Schritt, kurz vor der Zusammenfassung.

View File

@@ -1,25 +1,44 @@
import { SvNummerField } from "@/components/employees/SvNummerField";
import { TitleFields } from "@/components/employees/TitleFields";
import { SelectField, TextField } from "@/components/ui/Field";
import { PersonalnummerField } from "./PersonalnummerField";
import type { HireDraftData } from "./types";
type StepPersonProps = {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
locations: { id: string; name: string; country: string }[];
/** Meldet die geprüfte Personalnummer und ihr Ergebnis — steuert „Weiter". */
onNummerBefund?: (befund: { nummer: number; vergeben: boolean }) => void;
/**
* Beim Wiedereintritt: die Nummer steht fest und wird nur gezeigt.
*
* Zwei Gründe. Es ist dieselbe Person und sie behält ihre Nummer — und die
* Prüfung auf Dubletten fände hier zwangsläufig einen Treffer, nämlich sie
* selbst, und sperrte das Formular mit einer Meldung, die stimmt und
* trotzdem in die Irre führt.
*/
nummerGesperrt?: boolean;
};
export function StepPerson({ draft, update, locations }: StepPersonProps) {
export function StepPerson({ draft, update, locations, onNummerBefund, nummerGesperrt }: StepPersonProps) {
return (
<div className="flex flex-col gap-4">
{nummerGesperrt ? (
<TextField
label="Personalnummer"
required
inputMode="numeric"
value={draft.personnelNumber}
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
onChange={() => {}}
disabled
hint="Bleibt bei einem Wiedereintritt dieselbe."
/>
) : (
<PersonalnummerField
wert={draft.personnelNumber}
onChange={(personnelNumber) => update({ personnelNumber })}
onBefund={onNummerBefund ?? (() => {})}
/>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />

View File

@@ -50,7 +50,7 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
["Angestellte:r / Arbeiter:in", draft.workerType],
["Beschäftigtengruppe", draft.workerType],
["Kollektivvertrag", draft.collectiveAgreement],
["Arbeitstage", draft.workDays.join(", ") || "–"],
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],

View File

@@ -1,6 +1,6 @@
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
import { SelectField, TextField } from "@/components/ui/Field";
import type { PaygradeType } from "@/lib/supabase/types";
import type { PaygradeType } from "@/lib/types";
import type { HireDraftData } from "./types";
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [

View File

@@ -1,4 +1,5 @@
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, Mitarbeiterart, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types";
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
// though both are NOT NULL on employees — added here (defaults keep them
@@ -56,9 +57,19 @@ export type HireDraftData = {
angehoerige: HireDraftAngehoerige[];
isLateraleFuehrung: boolean;
isCLevel: boolean;
/** Form der Beschäftigung — zweite Achse neben der Beschäftigtengruppe. */
mitarbeiterart: Mitarbeiterart;
hasKuendigungsschutz: boolean;
/** Freiwillig — leer heisst „bis auf Weiteres". */
kuendigungsschutzBis: string;
/** Personenkreis nach dem besonderen Kündigungsschutz; leer heisst „nicht erfasst". */
kuendigungsschutzGrund: string;
kuendigungsschutzAb: string;
istBeguenstigtBehindert: boolean;
/** Grad in Prozent als Text — ein leeres Zahlenfeld wäre sonst 0. */
behinderungGrad: string;
behinderungAb: string;
behinderungBis: string;
};
export const EMPTY_HIRE_DRAFT: HireDraftData = {
@@ -93,6 +104,13 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
angehoerige: [],
isLateraleFuehrung: false,
isCLevel: false,
mitarbeiterart: MITARBEITERART_STANDARD,
hasKuendigungsschutz: false,
kuendigungsschutzBis: "",
kuendigungsschutzGrund: "",
kuendigungsschutzAb: "",
istBeguenstigtBehindert: false,
behinderungGrad: "",
behinderungAb: "",
behinderungBis: "",
};

View File

@@ -171,7 +171,7 @@ export function ImportWorkbench() {
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
return (
<section className={`overflow-x-auto ${CARD_CLASS}`}>
<section className={`overflow-x-auto overflow-y-hidden ${CARD_CLASS}`}>
<h2 className="px-5 pt-5 text-sm font-bold text-ink">
{titel} <span className="font-normal text-ink-muted">({befunde.length})</span>
</h2>

View File

@@ -156,7 +156,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
<div key={e.id}>
<div
ref={e.id === focusId ? focusRef : undefined}
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-accent-200" : ""}`}
style={{ paddingLeft: depth * 24 + 8 }}
>
{hasChildren ? (

View File

@@ -23,11 +23,15 @@ import type { ChartNode, ChartNodeKind } from "./types";
// object as a change and remounts every custom node otherwise.
const NODE_TYPES = { orgNode: OrgChartNode };
// React Flow nimmt echte Farbwerte, keine Klassen — deshalb stehen die
// Token hier als Literal. Sie folgen KIND_ACCENT in OrgChartNode; laeuft das
// eine dem anderen davon, zeigt die Uebersichtskarte andere Farben als die
// Karten, ueber die sie liegt.
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
person: "#d6046e",
role: "#5c2e91",
group: "#c9b3c0",
vacancy: "#f6cfe2",
person: "#164194", // brand-500, Manner Blau
role: "#54277f", // purple-text
group: "#756c6e", // ink-muted
vacancy: "#f69686", // accent-500, Manner Rosa
};
export type GraphOrgChartProps = {
@@ -118,7 +122,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
fitView
fitViewOptions={{ padding: 0.2 }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#eedde6" />
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#f0dbd4" />
<Controls showInteractive={false} />
{/* Hidden under lg via CSS: on a phone it would cover a real
fraction of the canvas for little navigational benefit. */}
@@ -126,8 +130,8 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
pannable
zoomable
ariaLabel="Übersichtskarte"
maskColor="rgba(249, 241, 245, 0.75)"
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
maskColor="rgba(253, 246, 244, 0.75)"
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#164194"}
nodeStrokeWidth={0}
nodeBorderRadius={3}
/>

View File

@@ -25,14 +25,18 @@ const KIND_ACCENT: Record<ChartNodeKind, string> = {
person: "bg-brand-500",
role: "bg-purple-text",
group: "bg-ink-muted",
vacancy: "bg-brand-200",
// Rosa statt einer blassen Blaustufe: eine offene Stelle ist kein
// schwaecheres Abbild einer Person, sondern etwas anderes. Gegen das
// Blau der Person ist der Unterschied auch dann noch zu sehen, wenn
// herausgezoomt nur noch die Streifen uebrig sind.
vacancy: "bg-accent-500",
};
const KIND_SHELL: Record<ChartNodeKind, string> = {
person: "border-border bg-white",
role: "border-border bg-white",
group: "border-border-subtle bg-surface",
vacancy: "border-dashed border-brand-200 bg-brand-50",
vacancy: "border-dashed border-accent-500 bg-accent-50",
};
// React Flow re-renders node components on every pan/zoom frame — memo is
@@ -48,7 +52,7 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
{kind === "person" && avatar ? (
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
) : kind === "vacancy" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-brand-200 text-brand-500">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500 text-accent-700">
<Plus className="h-3.5 w-3.5" />
</span>
) : kind === "role" ? (

View File

@@ -538,10 +538,12 @@ function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: nu
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
// Nach unten hin blasser: so bleibt die Rangfolge auch auf dem
// Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen.
// Vier Stufen einer Farbe statt drei plus der Seitenflaeche: so ist es
// eine Reihe und nicht eine Reihe mit einem Fremdkoerper am Ende.
Gesellschaft: "bg-brand-500 text-white",
Bereich: "bg-brand-100 text-ink",
Abteilung: "bg-brand-50 text-ink",
Team: "bg-surface text-ink",
Bereich: "bg-brand-200 text-ink",
Abteilung: "bg-brand-100 text-ink",
Team: "bg-brand-50 text-ink",
};
function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) {

View File

@@ -1,9 +1,9 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Copy, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { deletePosition } from "@/actions/positions";
import { clonePosition, deletePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
@@ -25,6 +25,7 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [cloningId, setCloningId] = useState<string | null>(null);
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
// und „das entsteht erst". In einer Liste vermischt liest sich eine
@@ -32,6 +33,18 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
const offen = openPositions.filter((p) => !p.future);
const kuenftig = openPositions.filter((p) => p.future);
async function handleClone(p: OpenPositionWithDays) {
setCloningId(p.id);
const result = await clonePosition({ position_id: p.id });
setCloningId(null);
if (result.success) {
showToast(`Planstelle ${p.position_number} kopiert.`);
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Klonen.", "error");
}
}
async function handleDelete(id: string) {
setDeletingId(id);
const result = await deletePosition(id);
@@ -77,15 +90,33 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
)}
</button>
<span className="absolute right-2 top-2 flex items-center gap-0.5">
{/* Nicht bei einer Leitung: je Einheit gibt es genau eine, ein Klon
wäre also entweder ein Fehler aus dem Maschinenraum oder eine
Planstelle, die stillschweigend anders ist als ihre Vorlage. Die
SQL-Funktion weist es ohnehin ab — der fehlende Knopf erspart
den Weg dorthin. */}
{!p.is_chief && (
<Button
variant="icon"
onClick={() => handleClone(p)}
pending={cloningId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) klonen`}
title="Klonen — gleiche Einheit, Tätigkeit und Kostenstelle, neue Nummer"
>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="icon"
onClick={() => handleDelete(p.id)}
pending={deletingId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
className="absolute right-2 top-2 hover:!text-danger-solid"
className="hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</span>
</div>
);
}

View File

@@ -22,6 +22,8 @@ import {
AVERAGE_MEASURES,
EVENT_DATE_OPEN,
EVENT_GROUP_LABELS,
EREIGNIS_AUSWAHL,
EREIGNIS_UNTERTYP,
EVENT_REPORT_PRESETS,
EVENT_TYPE_LABELS,
GROUP_LABELS,
@@ -31,16 +33,20 @@ import {
sortKeysForDimension,
STATUS_OPTIONS,
todayIso,
untertypOptionen,
type EventGroupDimension,
type GroupDimension,
type Measure,
type ReportRow,
type UnitOption,
} from "@/lib/reports";
import type { HistoryEventType } from "@/lib/supabase/types";
import type { HistoryEventType } from "@/lib/types";
const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"];
const EVENT_TYPES = Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[];
const EVENT_TYPES = EREIGNIS_AUSWAHL;
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
const VORSCHAU_NAMEN = 12;
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
type OrgOption = { id: string; name: string };
@@ -69,6 +75,8 @@ type EventsProps = CommonProps & {
eventGroup: EventGroupDimension;
eventSplit: EventGroupDimension | "";
eventType: string;
/** Beendigungsart bzw. Art der Langzeitabwesenheit; leer heisst „alle". */
eventSubtype: string;
eventFilters: { division: string; location: string; from: string; to: string };
};
@@ -227,6 +235,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const searchParams = useSearchParams();
const { showToast } = useToast();
const [expandedKey, setExpandedKey] = useState<string | null>(null);
// Welche aufgeklappte Zeile ihre Namen **vollständig** zeigt. Vorher stand
// dort „+794 weitere" als blosser Text: die Auskunft, dass es mehr gibt,
// ohne einen Weg dorthin. Wer die Liste brauchte, musste sie exportieren.
const [volleListeKey, setVolleListeKey] = useState<string | null>(null);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [newReportName, setNewReportName] = useState("");
const [savingReport, setSavingReport] = useState(false);
@@ -439,12 +451,35 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
label="Ereignistyp"
dense
value={props.eventType}
onChange={(v) => updateParams({ eventType: v || undefined })}
// Der Untertyp gehört zum Ereignistyp und fällt mit ihm weg.
// Bliebe er in der Adresse stehen, filterte eine Auswahl
// weiter, die auf dem Bildschirm nicht mehr zu sehen ist.
onChange={(v) => updateParams({ eventType: v || undefined, eventSubtype: undefined })}
options={[
{ value: "", label: "Alle Ereignistypen" },
...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })),
]}
/>
{/* Nur dort, wo es einen gibt: eine Versetzung hat keine
Beendigungsart, und ein leeres zweites Feld wäre eine Frage
ohne Antwortmöglichkeit. */}
{(() => {
const typ = props.eventType ? (props.eventType as HistoryEventType) : null;
const untertyp = typ ? EREIGNIS_UNTERTYP[typ] : undefined;
if (!untertyp) return null;
return (
<SelectField
label={untertyp.label}
dense
value={props.eventSubtype}
onChange={(v) => updateParams({ eventSubtype: v || undefined })}
options={[
{ value: "", label: untertyp.alle },
...untertypOptionen(typ).map((o) => ({ value: o, label: o })),
]}
/>
);
})()}
<SelectField
label="Gruppieren nach"
dense
@@ -677,7 +712,16 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const isExpanded = expandedKey === row.key;
return (
<div key={row.key}>
<button type="button" onClick={() => setExpandedKey(isExpanded ? null : row.key)} className="w-full text-left">
<button
type="button"
onClick={() => {
setExpandedKey(isExpanded ? null : row.key);
// Zuklappen setzt auch die volle Liste zurück: sonst
// stünde sie beim nächsten Aufklappen unerwartet offen.
setVolleListeKey(null);
}}
className="w-full text-left"
>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-semibold text-ink">{row.key}</span>
<span className="text-ink-body">
@@ -695,25 +739,43 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
)}
</div>
</button>
{isExpanded && (
{isExpanded &&
(() => {
const alle = volleListeKey === row.key;
const sichtbar = alle ? row.people : row.people.slice(0, VORSCHAU_NAMEN);
const rest = row.people.length - VORSCHAU_NAMEN;
return (
<div className="mt-2 rounded border border-border bg-surface p-3">
<ul className="flex flex-col divide-y divide-border">
{row.people.slice(0, 12).map((p, i) => (
<li key={`${p.id}-${i}`} className="flex items-center justify-between py-1.5 text-sm">
{/* Bei achthundert Namen scrollt die Liste in sich,
statt die Auswertung darüber aus dem Bild zu
schieben. */}
<ul className={`flex flex-col divide-y divide-border ${alle ? "max-h-[28rem] overflow-y-auto" : ""}`}>
{sichtbar.map((p, i) => (
<li key={`${p.id}-${i}`} className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
{p.name}
</Link>
<span className="text-xs text-ink-muted">
<span className="text-right text-xs text-ink-muted">
{p.title} · {p.team} · {fmtDate(p.entry_date)}
</span>
</li>
))}
</ul>
{row.people.length > 12 && <p className="mt-2 text-xs text-ink-muted">+{row.people.length - 12} weitere</p>}
</div>
{rest > 0 && (
<button
type="button"
onClick={() => setVolleListeKey(alle ? null : row.key)}
className="mt-2 rounded text-xs font-semibold text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{alle ? `Nur die ersten ${VORSCHAU_NAMEN} zeigen` : `+${rest} weitere anzeigen`}
</button>
)}
</div>
);
})()}
</div>
);
})}
{rows.length === 0 && <p className="text-sm text-ink-muted">Keine Daten für diese Auswahl.</p>}
</div>

View File

@@ -8,17 +8,39 @@ import { Topbar } from "./Topbar";
// Owns the one piece of state the shell needs (is the mobile drawer open),
// so app/(app)/layout.tsx can stay a Server Component and keep doing its
// auth check and data loading on the server.
export function AppShell({ userLabel, openNotes, children }: { userLabel: string; openNotes: OpenNote[]; children: ReactNode }) {
export function AppShell({
userLabel,
openNotes,
kollegen,
children,
}: {
userLabel: string;
openNotes: OpenNote[];
kollegen: { id: string; name: string; sichtbar: boolean }[];
children: ReactNode;
}) {
const [navOpen, setNavOpen] = useState(false);
const closeNav = useCallback(() => setNavOpen(false), []);
// Auf Papier gehört von der Hülle nichts hin: Navigation, Glocke und
// Abmelde-Knopf sind Bedienelemente, und gedruckt sind sie bestenfalls
// Zierrat — auf einer Checkliste, die in eine Personalakte geht, sogar
// irreführend. Die Regel steht hier und nicht auf der einzelnen Druckseite,
// damit jede weitere sie mitbekommt, statt sie zu wiederholen.
//
// Auch die Ränder fallen weg: die Satzfläche bestimmt @page auf der
// Druckseite selbst, und die Polsterung der Hülle käme obendrauf.
return (
<div className="min-h-dvh">
<div className="min-h-dvh print:min-h-0">
<div className="print:hidden">
<Sidebar open={navOpen} onClose={closeNav} />
<div className="flex min-h-dvh flex-col lg:ml-[236px]">
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6">
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
</div>
<div className="flex min-h-dvh flex-col lg:ml-[236px] print:ml-0 print:block print:min-h-0">
<div className="print:hidden">
<Topbar userLabel={userLabel} openNotes={openNotes} kollegen={kollegen} onOpenNav={() => setNavOpen(true)} />
</div>
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6 print:p-0">
<div className="mx-auto w-full max-w-[1280px] print:max-w-none">{children}</div>
</main>
</div>
</div>

View File

@@ -1,24 +1,47 @@
"use client";
import { Bell } from "lucide-react";
import { Bell, Users } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { completeEmployeeNote } from "@/actions/employees";
import { setNotizSichtbarkeit } from "@/actions/notes";
import { useToast } from "@/components/ui/Toast";
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { OpenNote } from "@/lib/notes";
export type Kollege = { id: string; name: string; sichtbar: boolean };
// Click-outside mechanics borrowed from CountryPicker (useRef + mousedown
// listener) — without its draft-text reset, which has no equivalent here.
export function NotesBell({ notes }: { notes: OpenNote[] }) {
export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Kollege[] }) {
const { showToast } = useToast();
const router = useRouter();
const [open, setOpen] = useState(false);
const [completingId, setCompletingId] = useState<string | null>(null);
const [zeigeEinstellung, setZeigeEinstellung] = useState(false);
// Der Haken springt sofort um und wird zurückgedreht, wenn das Speichern
// scheitert. Ohne das fühlt sich jeder Klick wie eine halbe Sekunde
// Bedenkzeit an — die Liste wird beim Durchgehen mehrfach angetippt.
const [eigen, setEigen] = useState<Record<string, boolean>>({});
const containerRef = useRef<HTMLDivElement>(null);
const sichtbarkeit = (k: Kollege) => eigen[k.id] ?? k.sichtbar;
const dazugewaehlt = kollegen.filter((k) => sichtbarkeit(k)).length;
async function schalte(k: Kollege) {
const neu = !sichtbarkeit(k);
setEigen((v) => ({ ...v, [k.id]: neu }));
const result = await setNotizSichtbarkeit({ kollegeId: k.id, sichtbar: neu });
if (result.success) {
router.refresh();
} else {
setEigen((v) => ({ ...v, [k.id]: !neu }));
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
@@ -43,10 +66,17 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
return (
<div ref={containerRef} className="relative">
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-ink-muted hover:bg-surface">
{/* brand-700 statt ink-muted: die Glocke sitzt im rosa Markenband der
Kopfzeile, und ink-muted kommt darauf nur auf 2.33:1. */}
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-brand-700 hover:bg-accent-100">
<Bell className="h-4 w-4" />
{notes.length > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-solid px-1 text-[10px] font-bold text-white">
// danger-text statt danger-solid: als Flaeche auf dem rosa Band kam
// das hellere Rot nur auf 2.78:1 und blieb damit unter den 3:1, die
// WCAG 1.4.11 fuer ein bedeutungstragendes Element verlangt. Der
// dunklere Ton derselben Familie erreicht 3.26:1, und die Ziffer
// darin steht mit 7.12:1 besser als vorher.
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-text px-1 text-[10px] font-bold text-white">
{notes.length}
</span>
)}
@@ -54,7 +84,58 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
{open && (
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]">
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
<span className="text-xs font-bold uppercase tracking-wide text-ink-muted">
Meine Notizen ({notes.length})
</span>
<button
type="button"
onClick={() => setZeigeEinstellung((v) => !v)}
aria-expanded={zeigeEinstellung}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
zeigeEinstellung ? "text-brand-700" : "text-ink-muted"
}`}
>
<Users className="h-3.5 w-3.5" />
Kolleg:innen
{/* Nur wenn jemand dazugewählt ist: eine „0" bei jedem
Aufschlagen zu lesen, sagt nichts. */}
{dazugewaehlt > 0 && <span className="text-brand-700">+{dazugewaehlt}</span>}
</button>
</div>
{zeigeEinstellung && (
<div className="border-b border-border bg-surface px-4 py-3">
{/* Dass die Auswahl auch die Entwürfe auf der Übersicht
steuert, steht hier und nicht nur dort: sie wird hier
getroffen, und eine Wirkung, die zwei Klicks entfernt
sichtbar wird, sucht sonst niemand. */}
<p className="mb-2 text-xs text-ink-muted">
Wessen Notizen hier und wessen Entwürfe auf der Übersicht zusätzlich erscheinen. Die
eigenen sind immer dabei.
</p>
{kollegen.length === 0 ? (
<p className="py-2 text-sm text-ink-muted">Keine weiteren HR-Kolleg:innen freigeschaltet.</p>
) : (
<ul className="flex flex-col gap-1">
{kollegen.map((k) => (
<li key={k.id}>
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-white">
<input
type="checkbox"
checked={sichtbarkeit(k)}
onChange={() => schalte(k)}
className="h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
/>
<span className={sichtbarkeit(k) ? "text-ink" : "text-ink-muted line-through"}>{k.name}</span>
</label>
</li>
))}
</ul>
)}
</div>
)}
{notes.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
) : (
@@ -69,6 +150,13 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
</div>
<p className="mt-1 text-sm text-ink-body">{n.note_text}</p>
{/* Von wem die Notiz stammt. Sichtbar sind die eigenen und
die hinzugewählten — ohne diese Zeile wäre nicht zu
unterscheiden, welche davon man gerade liest. „Ich"
statt des eigenen Namens: den kennt man. */}
<p className="mt-1 text-xs text-ink-muted">
{n.vonMir ? "von mir" : `von ${n.author_name}`}
</p>
{n.due_date && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
<button
type="button"

View File

@@ -4,6 +4,7 @@ import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X }
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { AppWortmarke } from "@/components/brand/Logo";
const NAV_ITEMS = [
{ href: "/", label: "Übersicht", icon: LayoutGrid },
@@ -55,13 +56,20 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border pl-5 pr-3">
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
{/* pl-2 statt pl-5: der Freiraum des Logos bringt seinen eigenen
Abstand mit (siehe components/brand/Logo.tsx), und beides
uebereinander schoebe die Wortmarke sichtbar aus der Flucht der
Navigationspunkte darunter. */}
{/* Rosa wie die Kopfzeile daneben: beide zusammen sind ein
durchgehendes Band ueber der Anwendung, in dem das Logo mit seinem
eigenen rosa Feld aufgeht statt als Rechteck darauf zu liegen. */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-accent-600/40 bg-accent-500 pl-2 pr-3">
<AppWortmarke hoehe={24} aufRosa textKlasse="text-brand-700" />
<button
type="button"
onClick={onClose}
aria-label="Navigation schließen"
className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface lg:hidden"
className="-mr-1 rounded p-2 text-brand-700 hover:bg-accent-100 lg:hidden"
>
<X className="h-5 w-5" />
</button>

View File

@@ -25,36 +25,50 @@ function titleFor(pathname: string): string {
type TopbarProps = {
userLabel: string;
openNotes: OpenNote[];
kollegen: { id: string; name: string; sichtbar: boolean }[];
onOpenNav: () => void;
};
export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
export function Topbar({ userLabel, openNotes, kollegen, onOpenNav }: TopbarProps) {
const pathname = usePathname();
return (
// Sticky, with the notch inset added to its top padding: on an iPhone in
// landscape the bar would otherwise sit under the rounded corner.
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between border-b border-border bg-white px-3 pt-[env(safe-area-inset-top)] sm:px-6">
//
// Rosa und nicht Weiss: zusammen mit dem Kopf der Seitenleiste bildet
// dieser Streifen das durchgehende Markenband ueber der Anwendung. Auf
// Weiss stand das Logo daneben als ausgeschnittenes Rechteck, weil es
// sein rosa Feld mitbringt; in einem rosa Band geht es darin auf.
//
// Geschrieben wird darauf in brand-700 und nicht in ink: Schwarz auf Rosa
// traegt zwar (7.47:1), sieht aber aus wie Text, der aus Versehen auf der
// Marke gelandet ist. Dunkelblau auf Rosa ist dieselbe Paarung, aus der
// der Schriftzug selbst besteht, und kommt auf 6.60:1.
//
// Gedaempftes gibt es hier nicht mehr: ink-muted liegt auf Rosa bei
// 2.33:1. Gestuft wird ueber Groesse und Gewicht.
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between border-b border-accent-600/40 bg-accent-500 px-3 pt-[env(safe-area-inset-top)] sm:px-6">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={onOpenNav}
aria-label="Navigation öffnen"
className="-ml-1 rounded p-2 text-ink-body hover:bg-surface lg:hidden"
className="-ml-1 rounded p-2 text-brand-700 hover:bg-accent-100 lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="truncate text-base font-bold text-ink">{titleFor(pathname)}</h1>
<h1 className="truncate text-base font-bold text-brand-700">{titleFor(pathname)}</h1>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-4">
<NotesBell notes={openNotes} />
<NotesBell notes={openNotes} kollegen={kollegen} />
<NewHireButton />
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-brand-700/25 sm:pl-4">
{/* The name is the first thing worth dropping on a narrow screen —
the account is still reachable via the sign-out control. */}
<span className="hidden font-semibold text-ink md:inline">{userLabel}</span>
<span className="hidden font-semibold text-brand-700 md:inline">{userLabel}</span>
<form action={logout}>
<button type="submit" aria-label="Abmelden" className="rounded p-2 text-ink-muted hover:bg-surface">
<button type="submit" aria-label="Abmelden" className="rounded p-2 text-brand-700 hover:bg-accent-100">
<LogOut className="h-4 w-4" />
</button>
</form>

View File

@@ -1,4 +1,4 @@
import type { AuditChange } from "@/lib/supabase/types";
import type { AuditChange } from "@/lib/types";
// Was sich geändert hat, feldweise — im Protokoll und in der Historie einer
// Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu

View File

@@ -14,7 +14,10 @@ const BASE =
const VARIANTS = {
primary: "bg-brand-500 text-white hover:bg-brand-600",
secondary: "border border-border bg-white text-ink-body hover:bg-surface",
// border-strong wie bei den Eingabefeldern: die Umrandung ist hier das
// Einzige, was den Knopf vom Hintergrund trennt, und muss deshalb die
// 3:1 aus WCAG 1.4.11 erreichen.
secondary: "border border-border-strong bg-white text-ink-body hover:bg-surface",
ghost: "text-ink-body hover:bg-surface",
danger: "bg-danger-solid text-white hover:brightness-110",
// Square icon-only button; pair with an aria-label.

View File

@@ -12,16 +12,21 @@ import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttribu
// impossible to get wrong, and gives every field one place to fix focus
// styling, error display and sizing.
// `border-strong` und nicht `border`: der blasse Kartenrahmen kommt gegen
// Weiss auf 1.3:1 und ist als Schmuck in Ordnung, die Umrandung eines
// Bedienelements aber nicht — WCAG 1.4.11 verlangt dafuer 3:1, und ohne sie
// ist auf einem hellen Bildschirm nicht zu sehen, wo ein Feld anfaengt.
// border-strong liegt bei 3.56:1.
export const CONTROL_CLASS =
"w-full rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
"w-full rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500 " +
"disabled:cursor-not-allowed disabled:bg-surface disabled:text-ink-muted";
"disabled:cursor-not-allowed disabled:border-border disabled:bg-surface disabled:text-ink-muted";
const INVALID_CLASS = "border-danger-solid";
/** Auto-width select for filter bars, where the label is an aria-label. */
export const FILTER_SELECT_CLASS =
"rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
"rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500";
type FieldChildProps = {

View File

@@ -126,7 +126,7 @@ export function Lookup<T>({
return (
<div ref={containerRef} className="relative">
<div className="flex items-center gap-2 rounded border border-border bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500">
<div className="flex items-center gap-2 rounded border border-border-strong bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500">
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
<input
id={id}

View File

@@ -25,7 +25,7 @@ export function SearchInput({
const id = useId();
return (
<div
className={`flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500 ${className}`}
className={`flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border-strong bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500 ${className}`}
>
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
<label htmlFor={id} className="sr-only">

View File

@@ -36,8 +36,27 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }:
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-[var(--shadow-overlay)] outline-none transition-transform duration-200 ${
open ? "translate-x-0" : "translate-x-full"
// ── Warum der Schatten am geschlossenen Panel wegfällt ──────────
//
// Geschlossen steht das Panel per `translate-x-full` genau hinter dem
// rechten Bildschirmrand: die eigene linke Kante liegt 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 fällt das nicht auf. Die Protokollseite rendert
// jedoch eines **je Zeile**, also fünfzig übereinander an derselben
// Stelle. Bei 18 % Deckkraft bleiben nach fünfzig Lagen rechnerisch
// 0,01 % Durchsicht — ein fast schwarzer Streifen neben der
// Bildlaufleiste, mit sichtbaren Stufen dort, wo sich die
// Weichzeichnungen der einzelnen Lagen überlagern. Genau das hat der
// Kunde gemeldet.
//
// Die Tiefe gehört zum geöffneten Panel, nicht zum weggeschobenen.
// Sie erst beim Öffnen zu setzen kostet nichts und nimmt dem Stapel
// seine Wirkung.
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white outline-none transition-transform duration-200 ${
open ? "translate-x-0 shadow-[var(--shadow-overlay)]" : "translate-x-full"
}`}
>
<div className="flex shrink-0 items-start justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-6 sm:py-4">

View File

@@ -1,19 +1,46 @@
import { absenceLabel } from "@/lib/absence";
import { STATUS_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { EmploymentStatus } from "@/lib/supabase/types";
import { deriveStatusAsOf } from "@/lib/reports";
type StatusChipProps = {
status: EmploymentStatus;
entryDate?: string | null; // shown as "Eintritt {date}" when status is Geplant
// ── Warum der Status hier abgeleitet und nicht übergeben wird ────────
//
// Vorher nahm dieses Bauteil einen fertigen Status entgegen, und beide
// Aufrufer reichten `employees.status` durch — die gespeicherte Spalte. Die
// Liste daneben filtert aber über die Datumsspalten (lib/employee-status-
// filter.ts), und sobald die Spalte nachhängt, widersprechen sich Filter und
// Beschriftung: der Filter „Ausgetreten" fand 48 Personen, von denen mehrere
// als „Aktiv" beschriftet waren.
//
// Nachhängen kann sie regelmässig. terminate_employee (Migration
// 20260814100000) setzt die Spalte nur, wenn das Austrittsdatum nicht in der
// Zukunft liegt — „es gibt keinen Lauf, der das später nachzieht" steht dort
// als Kommentar. Ein Austritt, der zum Zeitpunkt der Erfassung noch bevorstand,
// lässt die Spalte also für immer auf „Aktiv" stehen.
//
// Deshalb nimmt das Bauteil jetzt die Zeile und den Stichtag und leitet
// selbst ab. Die Spalte lässt sich nicht mehr hineinreichen: die zweite
// Quelle ist nicht bloss ungenutzt, es gibt sie hier nicht mehr.
export type StatusChipEmployee = {
entry_date: string;
exit_date: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
/** Shown instead of the generic label when the kind of absence is known. */
absenceType?: string | null;
absence_type?: string | null;
};
export function StatusChip({ status, entryDate, absenceType }: StatusChipProps) {
// The stored status is still 'Karenz'; absenceLabel maps it to
// "Langzeitabwesenheit", or to the specific kind when one is recorded.
const label = status === "Geplant" && entryDate ? `Eintritt ${fmtDate(entryDate)}` : absenceLabel(status, absenceType);
type StatusChipProps = {
employee: StatusChipEmployee;
/** Der Tag, zu dem der Status gilt — derselbe, nach dem die Seite filtert. */
asOf: string;
};
export function StatusChip({ employee, asOf }: StatusChipProps) {
const status = deriveStatusAsOf(employee, asOf);
// Der abgeleitete Status ist 'Karenz'; absenceLabel macht daraus
// "Langzeitabwesenheit", oder die konkrete Art, wenn eine erfasst ist.
const label = status === "Geplant" ? `Eintritt ${fmtDate(employee.entry_date)}` : absenceLabel(status, employee.absence_type ?? null);
return (
<span
className={`inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLES[status]}`}

Some files were not shown because too many files have changed in this diff Show More