Compare commits

26 Commits

Author SHA1 Message Date
96c94642ba Datumsfelder lassen sich wieder tippen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m50s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m13s
In den Berichten hing jedes Datumsfeld unmittelbar an der Adresszeile: eine
Aenderung hiess router.push, also neu laden und neu rendern. Ein
<input type="date"> meldet beim Tippen der Jahreszahl aber viermal -- 0002,
0020, 0202, 2026 -- und die ersten drei loesten je eine Navigation aus, die das
Feld auf den Stand aus der Adresse zuruecksetzte. Mitten im Tippen. Mit dem
Kalender ging es, weil der in einem Zug ein fertiges Datum setzt; das war der
Hinweis darauf, wo es klemmt.

DateField haelt den Tippstand jetzt bei sich und meldet nur, was eine Aussage
ist: ein vollstaendiges Datum mit vierstelliger Jahreszahl ab 1000, oder das
Leeren des Feldes. Beim Verlassen wird nachgereicht, was liegengeblieben ist.
Der Stichtag im Organigramm ist ein blankes input und benutzt dieselbe Regel
ueber istMeldbaresDatum -- zwei Fassungen davon liefen hier erfahrungsgemaess
auseinander.

Der Abgleich mit dem Wert von aussen laeuft waehrend des Renderns, nicht in
einem Effekt: der liefe erst nach dem Zeichnen, das Feld zeigte also fuer einen
Bildaufbau den alten Stand -- und die Regel gegen setState im Effekt verbietet
ihn aus genau diesem Grund.

Die Felder in den Panels bleiben, wie sie sind: dort ist der Zustand lokal, es
gibt keine Navigation und damit auch kein Zuruecksetzen.
2026-09-29 21:39:35 +02:00
d9966c9623 Personalnummer im gedruckten Organigramm, wahlweise
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m23s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m13s
Von Max gewuenscht, ausdruecklich mit Auswahl oben. Ein Haekchen in "Was soll
aufs Papier?" stellt die Nummer hinter den Namen -- nicht in eine eigene Zeile:
auf einem Blatt mit zweihundert Kaesten kostet jede zusaetzliche Zeile
Massstab, und der entscheidet darueber, ob das Blatt noch lesbar ist.

Angeboten auf jeder Tiefe, nicht nur bei "mit Personen". Die Leitung einer
Einheit steht auch auf den Blaettern, die sonst keine Namen fuehren; ein
Haekchen, das dort fehlte, saehe aus, als gaebe es dort keine Namen.

Als Kontext statt als Eigenschaft durch den Baum: die Angabe betrifft eine
einzige Zeile ganz unten, muesste aber sonst durch vier Signaturen der
Seitenerzeugung durchgereicht werden. Und die Kennung der Messung traegt den
Schalter mit -- die Zeilen werden breiter, also muss der Massstab neu gemessen
werden, sonst liefe das Blatt ueber den Rand.
2026-09-29 21:03:50 +02:00
0039ce5069 Headcount heisst ueberall dasselbe
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m12s
"Headcount" stand auf der Uebersicht fuer die Aktiven und im Bericht fuer eine
andere Zahl -- 785 gegen 788, dasselbe Wort fuer Verschiedenes (N.02). Max hat
die Begriffe festgelegt: Headcount Aktiv fuer die Aktiven, Headcount Aktives
Dienstverhaeltnis fuer Aktive plus Langzeitabwesende.

Die beiden Kacheln auf der Uebersicht tragen jetzt genau diese Namen. Beide
Zahlen gab es dort schon, nur hiess die eine "Aktives Dienstverhaeltnis" und
die andere "Aktive Mitarbeiter:innen (HC)".

Im Bericht ginge ein fester Name nicht: die Kennzahl zaehlt, was gerade
ausgewaehlt ist, auch Geplante oder Ausgetretene. Trifft die Auswahl eine der
beiden Groessen, steht ihr Name in der Ueberschrift; sonst steht dabei, welche
Status gezaehlt wurden -- mit den Anzeigenamen, also "Langzeitabwesenheit" und
nicht "Karenz".
2026-09-29 20:36:22 +02:00
38f8c99968 Eine Befoerderung benennt die Planstelle um
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Bisher schrieb promote_employee den neuen Titel nach employees.job_title -- und
nur dorthin. Gesehen hat ihn niemand: Akte, Organigramm und die Exporte zeigen
alle die Taetigkeit der Planstelle. Haltbar war er auch nicht, denn
hire_employee, transfer_employee und change_employee_data setzen dieselbe
Spalte aus der Planstelle; die naechste Adressaenderung ueberschrieb ihn wieder.

Gemeldet von Lara und im Testprotokoll als H.10. Max hat entschieden: die
Befoerderung benennt die Planstelle um.

Umbenannt wird die Stelle, auf der die Person danach sitzt -- die Zielstelle,
sonst die bisherige. Im Formular ist das ohne Ueberraschung, weil es beim
Waehlen einer Zielplanstelle deren Taetigkeit als Vorschlag eintraegt: wer
nichts aendert, benennt auch nichts um. Und umbenannt wird nicht der
Katalogeintrag, sondern die Planstelle zeigt auf einen anderen -- den Eintrag
selbst umzubenennen traefe jede Planstelle mit derselben Taetigkeit.

Die Regel "gleiche Taetigkeit, ein Katalogeintrag" stand nur in
create_position und wandert nach job_fuer_titel(); zwei Fassungen derselben
Regel laufen hier erfahrungsgemaess auseinander. Die Nummernvergabe nimmt dabei
die erste freie statt count(*) + 1 -- gezaehlt wurde bisher, und das vergibt
eine belegte Nummer, sobald ein Eintrag geloescht wurde oder Codes aus einer
fremden Quelle danebenstehen, wie bei der Uebernahme der Manner-Daten.

Der Nachtlauf tut dasselbe, sonst benennt eine datierte Befoerderung am
Stichtag nichts um. Der Rauchtest prueft beide Wege und dass der alte
Katalogeintrag stehen bleibt.
2026-09-29 20:29:56 +02:00
fe858c6c24 YTD heisst bis heute, nicht bis zum Jahresende
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Die Kacheln "Eintritte/Austritte (YTD)" zaehlten bis zum 31.12. und damit auch,
was erst bevorsteht: ein fuer den 01.11. erfasster Austritt stand schon im
September als geschehen da. Die Kachel sagte 2, tatsaechlich war es 1.

Der Kommentar an der Kachel nannte die Absicht seit jeher richtig -- "seit
Jahresbeginn bis heute, nicht das ganze Kalenderjahr" --, nur stand darunter
yearEnd. Was kommt, steht ohnehin in "Anstehend" daneben; die Kachel soll
sagen, was war.

Der Verweis auf den Bericht traegt denselben Zeitraum, sonst zeigte der Bericht
eine andere Zahl als die Kachel, ueber die man ihn geoeffnet hat. Und die
Variable heisst jetzt ytdBis statt yearEnd -- ein Name, der "Jahresende" sagt
und "heute" bedeutet, waere die naechste Fundstelle gewesen.
2026-09-29 20:05:55 +02:00
cf5ed5c6c6 Vier Befunde, die still falsche Ergebnisse lieferten
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s
C.08 -- die Suche mit Bindestrich fand nichts. Das Feld wird per translate an
"-/:.," in Leerzeichen zerlegt, die Eingabe aber nicht: "Mueller-Weiss" erzeugte
das Muster mueller-weiss%, waehrend im Heuhaufen "mueller weiss" stand. Ohne
Bindestrich fand man dieselbe Person. Die Trennzeichen stehen jetzt einmal in
lib/employee-search.ts und werden von beiden Seiten des Vergleichs benutzt.

C.09 -- der Einheitenfilter verlor jede Person mit vorgemerktem Austritt.
"Laufend" war valid_to is null, aber terminate_employee setzt das Ende schon
beim Erfassen, Monate vor dem Tag. Jetzt zaehlt auch, was noch laeuft
(valid_to > heute). Bewusst ohne valid_from <= heute: ein geplanter Eintritt
gehoert in die Liste, sonst fiele er aus dem Filter, obwohl der Status
"Geplant" ihn ausdruecklich fuehrt.

E.07 -- nur die Kostenstelle zu aendern war unmoeglich. update_position weist
einen Aufruf ohne Aenderung ab, und die Umkontierung lief erst bei dessen
Erfolg. Sie wird jetzt uebersprungen, wenn sich an den Stammangaben nichts
geaendert hat.

H.06 -- jedes Speichern erzeugte zusaetzlich "Wochenstunden 30.0 -> 30". Der
Vergleich laeuft ueber Text, die Spalte ist numeric(4,1), und das Formular
schickt 30. Der Kommentar an der Zeile nannte die Absicht richtig, nur reicht
::numeric dafuer nicht -- es muss auf die Genauigkeit der Spalte gehen.
2026-09-29 19:47:48 +02:00
a0b9f6dc80 Anker aus der laufenden Definition, und ein Test, der sich selbst durchwinkt
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m15s
Zwei Fehler aus dem ersten Lauf des Rauchtests, beide von mir.

Der Anker fuer transfer_employee stammte aus 20260727120200 und fand nichts:
die Funktion ist seither dynamisch gepatcht worden, die Belegungsabfrage ist
zweizeilig und beruecksichtigt den Stichtag. Dieselbe Falle wie immer, nur
diesmal nicht im Rumpf einer Funktion, sondern im Anker auf sie -- fuer
hire_employee und rehire_employee stimmten die Anker, weil sie aus der zuletzt
erzeugten Fassung kamen. Die Migration war nie angewendet (sie bricht als
Ganzes ab), deshalb die Datei korrigiert statt eine neue geschrieben.

Und der Rauchtest hat sich an einer Stelle selbst durchgewunken: das `raise`
fuer den Fehlschlag stand innerhalb des Blocks mit exception-Zweig, wurde also
vom eigenen Handler gefangen -- und weil seine Meldung das Wort "vorgemerkte"
enthielt, bestand die Pruefung auf die erwartete Fehlermeldung. Der Schritt
galt als bestanden, obwohl die Migration gar nicht angewendet war. Jetzt merkt
sich der Block nur, ob es ging, und ausgewertet wird danach.
2026-09-29 19:24:58 +02:00
194b6d8916 Rauchtest: der Lebenszyklus, einmal wirklich ausgefuehrt -- und die fuenfte Fundstelle
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
rehire_employee war seit dem 17.09. bei jedem Aufruf kaputt, und keine Pruefung
konnte das bemerken: die Selbstpruefungen der Migrationen lesen den Text der
Funktionen, die Unit-Tests laufen ohne Datenbank, und die Integrationstests
haben keinen Bestand, gegen den sie liefen. db/tests/rauchtest.sql schliesst
die Luecke -- es ruft die Funktionen auf, gegen die echte Datenbank, in der
Reihenfolge des Lebenszyklus: anlegen, versetzen, befoerdern, vormerken,
austreten, wiedereintreten.

Zwei Eigenschaften, ohne die es gefaehrlich waere. Es laeuft als
Anwendungsrolle statt als Superuser -- sonst bewiese es nur, dass die
Funktionen fuer niemanden gehen, der sie benutzt. Und es rollt am Ende zurueck,
mit einer Zaehlung danach, die das belegt: beim Test vom 29.09. sind reale
Personen auf Testplanstellen umgezogen und dort geblieben.

Beim Schreiben fiel die fuenfte Stelle mit dem Paar aus Schliessen und
Einfuegen auf: terminate_employee. Wer heute eingestellt und heute wieder
ausgetragen wird -- ohne den Grund "No Show" --, lief in chk_assignment_range.
Die No-Show-Haelfte derselben Funktion macht es laengst richtig und erklaert
auch, warum; es galt nur fuer genau einen Austrittsgrund.
2026-09-29 19:17:33 +02:00
89ba34c901 Vorgemerkte Planstellen, Stichtag fuer Einheiten, Kuendigungsschutz und Elternteilzeit
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
F.24 -- eine Planstelle, auf die eine Versetzung vorgemerkt ist, galt als frei.
Die Vormerkung steht in pending_org_changes, "besetzt?" wurde allein an
position_assignments gefragt. Auffallen wuerde es erst in der Nacht des
Wirksamkeitstages: dort legt der Nachtlauf die zweite Besetzung an und laeuft in
den Teilindex, der genau eine laufende Besetzung je Planstelle zulaesst. Er
arbeitet in einer Transaktion ueber alle faelligen Vorgaenge -- eine einzige
solche Buchung haette ihn vollstaendig zum Stehen gebracht, auch fuer alle
anderen. Neue Auskunft planstelle_vorgemerkt, abgefragt in hire_employee,
rehire_employee, transfer_employee und promote_employee, mit Datum in der
Meldung. Die eigene Vormerkung zaehlt nicht als Hindernis.

D.06 -- die Struktursicht zeigte Einheiten, die es am gewaehlten Stichtag noch
nicht gab: org_units wurde ungefiltert gelesen, waehrend Planstellen und
Besetzungen daneben auf den Stichtag eingeschraenkt waren. Die Druckansicht
filterte schon immer richtig; jetzt tun es alle drei Stellen.

H.09 -- der Kuendigungsschutz stand in der Akte nur als "bis TT.MM.JJJJ",
obwohl Personenkreis, Beginn, Ende und die ganze Begunstigung erfasst werden.
Der Personenkreis ist die eigentliche Auskunft. Die Behinderung bekommt eine
eigene Zeile: sie fuehrt oft zu Kuendigungsschutz, ist aber ein eigener
Bescheid.

Elternteilzeit und Wiedereingliederungsteilzeit sind jetzt auch bei der
Stundenaenderung waehlbar. Sie standen nur bei der Rueckkehr aus einer
Abwesenheit, mit der Begruendung, dass sie typischerweise dann beginnen --
typischerweise ist aber nicht immer. Die Ueberschneidung der beiden Listen ist
damit gewollt; der Test prueft nicht mehr auf Partition, sondern darauf, dass
keine Teilzeit an keinem der beiden Wege haengt.
2026-09-29 19:10:31 +02:00
990ebdbc84 Versetzung und Befoerderung am Tag, an dem die Besetzung begann
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m24s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Wer heute schon einmal versetzt wurde, liess sich heute nicht noch einmal
versetzen oder befoerdern: die laufende Besetzung beginnt dann heute, und der
Code schliesst sie auf denselben Tag. chk_assignment_range verlangt aber
valid_to > valid_from -- ein Intervall von null Tagen ist verboten, und der
Vorgang brach mit der rohen Datenbankmeldung ab.

Das Verbot ist richtig, und der Fall ist im Haus schon einmal entschieden
worden: terminate_employee loescht die Besetzung beim Grund "No Show", statt
sie auf [Eintritt, Eintritt) zu schliessen -- niemand hatte sie je inne.
Dieselbe Begruendung gilt hier. Die zweite Versetzung am selben Tag
abzuweisen waere genau der haeufigste Fall gewesen: jemand hat die falsche
Planstelle erwischt und will es sofort richtigstellen. Beide Ereignisse
bleiben in der Historie stehen.

Vier Stellen mit demselben Paar aus Schliessen und Einfuegen: transfer_employee,
promote_employee und im Nachtlauf die Zweige transfer und promotion. Alle vier
aus der laufenden Definition gelesen und an einem Anker ergaenzt; die
Selbstpruefung zaehlt die beiden Stellen im Nachtlauf, statt nur ihr
Vorhandensein zu pruefen -- der Zweig transfer ist dort schon einmal spurlos
verschwunden.
2026-09-29 18:57:56 +02:00
be9ca4758f Wiedereintritt wieder moeglich, und die Panels lesen die Akte neu
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Zwei Befunde aus dem Test vom 29.09., beide Klasse A.

K.01 -- rehire_employee scheitert bei jedem Aufruf: der case-Ausdruck fuer den
Status liefert text, die Spalte ist ein Aufzaehlungstyp. Genau das wurde am
10.08. schon einmal behoben. Vier spaetere Migrationen haben die Funktion neu
erzeugt und den Zusatz nicht mitgenommen -- zwei davon aus einer aelteren
Datei, zwei aus der laufenden Definition. Daraus die Lehre, die vorher nicht
dastand: aus dem laufenden Stand zu erzeugen schuetzt davor, Verhalten zu
verlieren, nicht davor, einen bereits vorhandenen Fehler mitzunehmen. Die
Selbstpruefung benennt deshalb jetzt das Erwartete und nicht nur das Neue.

H.05 -- "Daten aendern" schrieb veraltete Werte zurueck. Die Panels bleiben
eingebunden, damit ihr Ein- und Ausfahren laufen kann, belegen ihre Felder aber
mit useState(employee.…) vor -- und das laeuft nur beim ersten Aufbau. Nach
einer Befoerderung brachte router.refresh() die frische Akte herein, der
Zustand im Panel blieb der von vorhin, und beim naechsten Speichern ging er als
Ganzes an change_employee_data: der eben gesetzte Hay-Grade stand wieder auf
dem alten Wert. Jedes Panel bekommt jetzt einen key aus employees.updated_at,
der sich bei jeder Aenderung an der Zeile bewegt und sonst nie. Der
Wiedereintritts-Assistent daneben macht es seit jeher so.
2026-09-29 18:42:30 +02:00
3664b9a435 Neuer Slogan, und Hay-Grades stehen als Leiter statt nach Haeufigkeit
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m48s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Auf der Anmeldeseite steht jetzt "Alles im Blick. Alles Manner." statt des
bisherigen Satzes.

Die Berichte sortieren Gruppen nach der Kennzahl, gross zuerst. Bei Bereich
oder Standort ist das die Antwort auf die Frage, die der Bericht stellt. Bei
einer Leiter ist es keine: HG09 vor HG10 ist die Reihenfolge, in der die Werte
sind, und nach Haeufigkeit umgestellt liest sie sich als Zufall.

Die Sonderbehandlung, die es fuer Wochentage schon gab, ist dafuer zu einer
Liste "Dimensionen mit eigener Reihenfolge" verallgemeinert -- Wochentage und
Hay-Grades stehen darin, und die naechste Leiter ist ein Eintrag statt einer
dritten Verzweigung. Gilt damit auch fuer die Aufschluesselung innerhalb einer
Gruppe und fuer die Spalten im Berichtsexport, die dieselbe Funktion nutzen.

Der Generic Grade steht vor HG09: er ist keine Stufe, sondern ihr Fehlen, und
vor der niedrigsten faellt das am wenigsten als Aussage auf.
2026-09-29 18:26:38 +02:00
2eda038c2e Kontierung auch bei besetzten Planstellen -- aus dem Organigramm heraus
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Die Kostenstelle liess sich an genau einer Stelle aendern: auf der Seite
Positionen, ueber die Karte einer Vakanz. Diese Seite zeigt aber nur unbesetzte
und kuenftige Planstellen. Bei einer besetzten war die Kontierung damit nirgends
zu erreichen -- im Bestand betrifft das 784 von 788, und dasselbe galt fuer ihre
Taetigkeit.

Die Angaben zu einer Einheit fuehren jetzt ihre Planstellen auf, besetzte
eingeschlossen, mit Inhaber:in und Kostenstelle. Ein Klick oeffnet denselben
Dialog wie auf Positionen, kein zweiter daneben. Was sich aendern laesst,
entscheidet weiterhin die Datenbank: bei einer besetzten Stelle bleiben Einheit
und Gueltigkeitsende gesperrt, weil ein Abteilungswechsel ueber eine Versetzung
gehoert und nicht ueber die Stelle.

Geladen wird erst beim Oeffnen und nur fuer diese eine Einheit. Alle 788
Planstellen in jede Zeichnung des Organigramms zu legen hiesse, fuer achtzig
Einheiten zu laden, was man fuer eine braucht.

EditPositionModal verlangt dafuer nicht mehr eine OpenPositionResolved, sondern
nur noch die Felder, die er tatsaechlich liest. Die alte Signatur war der
Grund, warum er nur dort aufzumachen war, wo unbesetzte Stellen entstehen.
2026-09-28 21:52:01 +02:00
ab50c21b8a Klick auf eine Einheit zeigt ihre Angaben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m56s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m7s
Die naheliegendste Geste in dieser Ansicht war ohne Wirkung: eine Karte im
Organigramm liess sich anklicken und antwortete nicht. Jetzt oeffnet sie eine
Leiste mit dem, was an der Einheit steht -- Orgnummer, Art, die Kette nach
oben, Gueltigkeit, Leitung, Belegschaft, unbesetzte Planstellen, die direkt
untergeordneten Einheiten und die Zahl der Personen im ganzen Teilbaum. Dazu
dieselben zwei Handlungen wie auf der Karte, nur beschriftet statt als Symbol.

Alles daraus stammt aus den Daten, die die Struktursicht ohnehin geladen hat.
Kein zusaetzlicher Gang zur Datenbank und damit auch keine zweite Wahrheit --
was die Leiste zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist.

Die Liste klickt sich genauso: beide Ansichten kommen aus einem Baum, und eine
Handlung, die es nur in einer von beiden gibt, findet man in der anderen nie.

org_units liefert dafuer zusaetzlich valid_from und valid_to, aber nur an die
Struktursicht. Der Druck kommt ohne sie aus; auf OrgUnitNode stehen sie deshalb
als optionale Felder und nicht als zwei weitere Spalten, die ueberall
mitgeschleppt werden.
2026-09-28 21:27:36 +02:00
a3dde61ec1 Eine leere Organisationseinheit wieder entfernen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Der Papierkorb neben dem Plus, im Organigramm wie in der Liste, und nur dort,
wo nichts darunter haengt. Ein Knopf, der beim Klick eine Absage erteilt, ist
schlechter als kein Knopf -- die Datenbank prueft es trotzdem, denn sie sieht
auch geschlossene Planstellen, die im Baum gar nicht gezeichnet sind.

Abgewiesen wird mit Grund: untergeordnete Einheiten, Planstellen (geschlossene
zaehlen mit), Kostenstellen, die darauf verweisen, und die Wurzel selbst.

Geloescht und nicht geschlossen, und das ist eine bewusste Einschraenkung.
delete_position macht seit dem OM-Umbau den Unterschied vor: besetzt gewesen →
valid_to, nie besetzt gewesen → weg. Fuer Einheiten laesst sich davon heute nur
die zweite Haelfte umsetzen, weil ein valid_to an einer Einheit zwar
eingetragen, aber nirgends gelesen wuerde -- weder Organigramm noch
orgMapsAbfragen, Berichte, Druck oder die Auswahl beim Anlegen einer Planstelle
schraenken org_units auf den Stichtag ein. Die geschlossene Einheit staende
ueberall weiter da, nur mit einem Datum, das niemand sieht. Das Schliessen zum
Stichtag kommt, wenn org_units gegen den Stichtag gelesen wird -- dieselbe
Arbeit, die auch das Verschieben braucht.
2026-09-28 16:51:26 +02:00
a6b6a7d67c Eine Organisationseinheit aus dem Organigramm heraus anlegen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m22s
Bisher gab es dafuer nur den Import. Jetzt sitzt auf jeder Einheit im
Organigramm -- in der Grafik wie in der Liste -- ein Plus, das eine
untergeordnete Einheit anlegt, wahlweise gleich mit Leitungsplanstelle.

Bewusst nur dieser eine Fall. Umbenennen, verschieben und schliessen fehlen
nicht aus Zeitmangel: org_units.parent_id traegt kein Datum, ein Verschieben
aenderte damit auch jede Auswertung auf einen vergangenen Stichtag, und der
fruehere Stand waere danach nirgends mehr ablesbar. Anlegen stellt diese Frage
nicht, weil vorher nichts da war -- und es kann dabei auch kein Kreis
entstehen, was hier mehr wiegt als es klingt: die rekursive Abfrage in
om_reporting_lines hat weder Tiefenbegrenzung noch Kreiserkennung.

Die Leitungsplanstelle entsteht ueber create_position statt durch eine zweite
Fassung derselben Logik -- dort haengen Jobkatalog, Nummernvergabe und die
Pruefung "je Einheit genau eine Leitung".

Die Orgnummer wird eingetragen, nicht vergeben. Vorgeschlagen wird die naechste
freie Nummer der bestehenden Reihe, und nur dann, wenn sich im Bestand genau
eine Systematik ablesen laesst -- eine plausibel aussehende, aber erfundene
Nummer prueft niemand nach.

Das Datum kommt nicht aus dem Stichtag der Ansicht. Wer sich die Struktur zum
letzten Jahresende ansieht und auf Plus drueckt, will in aller Regel eine
Einheit von heute anlegen.
2026-09-28 16:28:33 +02:00
8b7e32f14a Hay-Grade auch in "Daten aendern"
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m35s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Die Befoerderung konnte ihn schon setzen, "Daten aendern" nicht -- weder das
Formular noch change_employee_data kannten das Feld. Damit war eine Einstufung
nur ueber den Weg "Befoerderung" zu aendern, und eine Richtigstellung ("da
stand von Anfang an die falsche Stufe") ist keine Befoerderung: sie soll keine
Planstelle wechseln und kein Ereignis in der Historie hinterlassen.

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

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

Der Test zur Feldkarte liest jetzt alle Migrationen statt einer bestimmten. Der
feste Dateiname darin trug den Vermerk "die zuletzt gueltige Fassung" und war
schon zwei Migrationen spaeter falsch -- und ein Teil der Feldkarte kommt
inzwischen ohnehin aus einer Punktaenderung statt aus einer vollstaendigen
Fassung.
2026-09-28 15:06:58 +02:00
771020af72 LOLYO-Export: typografische Zeichen auf ASCII
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m21s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Der Gedankenstrich kam im Zielsystem als "–" an -- seine UTF-8-Bytes, gelesen
als Windows-1252. Nachgemessen hat die Vorlage des Anbieters dieselbe Kodierung
wie unsere Datei: UTF-8 ohne BOM, zu sehen an dem "bestätigt" in ihrer
Kopfzeile. Die Ursache liegt also nicht in dem, was wir schreiben, und solange
sie nicht geklaert ist, geht der Weg ueber das Zeichen selbst: ein Bindestrich
sagt dasselbe und ist in beiden Kodierungen dasselbe Byte.

Gilt fuer jede Zelle aus den Daten, nicht nur fuer die Position -- aufgefallen
ist es dort, weil die Taetigkeiten einen Gedankenstrich fuehren, aber dieselben
Zeichen stehen auch in Namen und Titeln.

Zwei Ausnahmen: Umlaute bleiben, weil "Baeckerei" ein Problem verstecken wuerde,
das dann auch Namen betraefe. Und das Passwort bleibt Zeichen fuer Zeichen, wie
es ist -- ein ersetztes Zeichen faellt niemandem auf, es gibt dann nur ein
Konto, in das niemand hineinkommt.
2026-09-28 14:05:41 +02:00
8ac4df17c1 Position im Export: die Taetigkeit der Planstelle, nicht die Spalte an der Person
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Die Berichtsseite loest die Position ueber die Besetzung auf -- die Taetigkeit
der Planstelle, auf der die Person am Stichtag sitzt, und nur ersatzweise
employees.job_title. Die Exporte nahmen nur die Spalte. Beides sind
unterschiedliche Angaben, also konnte in der Akte etwas anderes stehen als in
der Datei daneben, ohne dass eines von beidem falsch aussah.

Betrifft die Spalte "Position" im vollstaendigen Datenexport und im
LOLYO-Export.
2026-09-28 13:54:56 +02:00
38e35e2ff9 Kein Formelschutz in einer Datei, die eine Maschine einliest
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Das vorangestellte Hochkomma schuetzt einen Menschen, der eine CSV in Excel
oeffnet: ein Wert mit fuehrendem =, +, - oder @ waere dort sonst eine Formel.
In einer Load-Datei ist es das Gegenteil von Schutz. Jede oesterreichische
Telefonnummer beginnt mit "+", und in LOLYO stuende danach in jedem Konto
'+4366... als Nummer -- ohne dass es beim Erzeugen jemandem auffiele.

toCsv bekommt dafuer formelschutz in der CsvForm, voreingestellt an. Nur LOLYO
schaltet es ab. Die Telefonspalten von Cornerstone sind bisher leer, deshalb
bleibt die Datei dort, wie sie ist.
2026-09-28 13:47:49 +02:00
676cecbf15 Export fuer LOLYO, und die Zielsystem-Exporte in einem eigenen Abschnitt
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
LOLYO ist die dritte Datei, die in ein fremdes System geladen wird. Spalten,
Trennzeichen und Schreibweise kommen aus der Vorlage des Anbieters, Zeichen fuer
Zeichen -- einschliesslich der gemischten Kopfzeile ("Titel prefix" deutsch,
"Title Suffix" englisch). Semikolon und kein BOM: das BOM haenge unsichtbar am
Namen der ersten Spalte, und "Benutzer/Code" kaeme in keiner Zuordnung mehr vor.

Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das
hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene
Kennung, die wir treffen muessten.

Diese Datei legt Konten an, samt Passwort in der Zeile. Das Startpasswort steht
deshalb in der Route und nicht in lib/lolyo.ts: eine Route ist serverseitig, eine
lib-Datei waere nur so lange sicher, wie niemand sie aus einem Client-Bauteil
heraus einbindet -- und das saehe man dem Buendel nicht an. Dieselbe Ueberlegung
steht in lib/passwort.ts.

Die drei Zielsystem-Exporte stehen jetzt unter einer gemeinsamen Ueberschrift,
getrennt vom Datenexport darueber. Der gibt Zahlen heraus, die jemand ansieht;
diese legen im Zielsystem Datensaetze an. Nebeneinander in einer Reihe
gleichartiger Karten war das nicht zu sehen. Die Auswahl steht damit einmal
statt dreimal da.

Der erklaerende Text unter dem Cornerstone-Export entfaellt auf Wunsch des
Kunden.
2026-09-28 13:45:38 +02:00
af2ad53ef9 Die Exportkarten nennen die ganze Auswahl, nicht nur den Status
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m14s
Das Einschraenken der Downstream-Exporte gab es schon: exportHref reicht die
ganze Adresszeile weiter, und ladeExportMitarbeiter wendet dieselben Kriterien
an wie der Bericht daneben -- Eintritt ab, Austritt bis, Wochenstunden,
Beschaeftigungsart und die uebrigen. Nur sah man es den Karten nicht an. Dort
stand ausschliesslich der Status; alles andere steckt eingeklappt in "Weitere
Kriterien" weiter oben.

Damit sah eine Datei mit 40 Personen genauso aus wie eine mit 785. Fuer einen
Bericht ist das aergerlich, fuer ein Load-File in ein Zielsystem ist es ein
Datenstand, den dort niemand mehr hinterfragt: es fehlen Personen, und es sieht
nicht nach einem Filter aus.

Die drei Karten im Bestandsmodus zeigen jetzt Stichtag, Status, Einheit,
Standort, jedes gesetzte Kriterium und die Zeilenzahl. beschreibeKriterien
steht neben anzahlKriterien in derselben Datei und liest dieselben Listen -- ein
neues Kriterium erscheint damit von selbst auch auf den Karten. Ein Test haelt
beide gegeneinander, damit die Zahl am Aufklapper und der Text auf der Karte
nicht auseinanderlaufen.
2026-09-28 12:25:17 +02:00
677ca35df2 Manager ID verweist ueber die Cornerstone-ID, nicht ueber die Personalnummer
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m53s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m27s
Im Feld Manager stand die Personalnummer der vorgesetzten Person -- also der
Wert, der in derselben Zeile als Local System ID gefuehrt wird. Cornerstone
verknuepft aber ueber die User ID. Der Verweis lief damit entweder ins Leere
oder, schlimmer, auf jemand anderen, dessen User ID zufaellig aussieht wie
eine Personalnummer.

Leer, wenn die vorgesetzte Person selbst keine Cornerstone-ID traegt. Aus
demselben Grund wie bei User ID und Username: eine Kennung der falschen Art
ist schlimmer als keine, weil niemand ihr ansieht, dass sie falsch ist. Bei
435 von 785 Personen fehlt die Kennung noch -- das ist eine Luecke in der
Zuordnungstabelle, kein Fehler im Export.

Die Abbildung im Kontext heisst entsprechend managerKennung und traegt jetzt
Zeichenketten, damit der Typ selbst keine Nummer mehr zulaesst.
2026-09-28 11:13:55 +02:00
779beb4478 Hay-Grade statt Verwendungsgruppe A--F
A--F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene Stufen
mit erfundenen Beschreibungen. Der Kunde bewertet nach Hay und hat die Liste
geschickt -- 13 Stufen plus einen Generic Grade.

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

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

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

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

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

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

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

Erfasst wird das Feld in der Akte, in "Daten aendern", bei Einstellung
und Wiedereintritt sowie ueber den Massenimport; es steht im
Mitarbeiterexport und fuellt im Cornerstone-Export User ID und Username.
2026-09-28 10:13:52 +02:00
6717412c9f User ID and Username carry the Alpenwerk UUID
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m2s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Both columns now hold employees.id, and they hold the same value: in
Alpenwerk the user name and the user id never diverge. If they did,
Cornerstone would show two identities for one person and every
reference to them -- manager, reports -- could hit the wrong one.

The UUID rather than the personnel number, because it is the identifier
that never changes. A personnel number can be corrected, and the person
would then be somebody else in Cornerstone.

Local System ID keeps the personnel number as LOGA assigns it, which is
what that column is for. Customfield ID AD keeps the directory account
name (vorname.nachname); it is a separate thing from the Cornerstone
user name and always was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:25:41 +02:00
78 changed files with 5325 additions and 224 deletions

View File

@@ -6,7 +6,7 @@ import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, PaygradeType, 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);
@@ -85,6 +85,8 @@ export async function hireEmployee(payload: {
* der in unserem Namen Einladungen verschickt.
*/
company_email?: string;
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
cornerstone_id?: string;
phone?: string;
position_id?: string;
team_id?: string;
@@ -95,7 +97,7 @@ export async function hireEmployee(payload: {
contract_end_date?: string;
employment_type?: "Vollzeit" | "Teilzeit";
weekly_hours?: number;
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
paygrade?: PaygradeType;
source: "Intern" | "Extern";
worker_type?: WorkerType;
collective_agreement?: CollectiveAgreement;
@@ -214,7 +216,7 @@ export async function promoteEmployee(payload: {
employee_id: string;
effective_date: string;
new_title: string;
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
new_paygrade?: PaygradeType;
/**
* Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt.
*
@@ -281,6 +283,7 @@ export async function changeEmployeeData(payload: {
export async function rehireEmployee(payload: {
employee_id: string;
company_email?: string;
cornerstone_id?: string;
rehire_date: string;
position_id: string;
/**

47
actions/org.ts Normal file
View File

@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
/**
* Eine Organisationseinheit unter einer bestehenden anlegen.
*
* Bewusst die einzige Änderung an der Struktur, die es aus der Anwendung
* heraus gibt. Umbenennen, verschieben und schliessen fehlen nicht aus
* Zeitmangel: org_units.parent_id trägt kein Datum, ein Verschieben änderte
* damit auch jede Auswertung auf einen vergangenen Stichtag. Siehe Migration
* 20260929120000.
*/
const ORG_PFADE = ["/orgchart", "/positions", "/"];
export async function createOrgUnit(payload: {
parent_id: string;
org_number: string;
name: string;
unit_type: "Bereich" | "Abteilung" | "Team";
valid_from: string;
/** Leer lassen heisst: die Einheit entsteht ohne Leitung. */
leitung_taetigkeit?: string;
}): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), "create_org_unit", payload);
if (!result.success) return result;
// Die Einheit taucht in drei Ansichten auf, und in allen dreien wäre sie
// sonst erst nach dem nächsten harten Neuladen zu sehen.
for (const path of ORG_PFADE) revalidatePath(path);
return result;
}
/**
* Eine Organisationseinheit entfernen.
*
* Geht nur, solange nichts darunter hängt und nie etwas hing. Alles andere
* weist die Datenbankfunktion mit dem Grund ab — ein Schliessen zum Stichtag
* gibt es nicht, weil org_units nirgends gegen den Stichtag gelesen wird.
*/
export async function deleteOrgUnit(orgUnitId: string): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), "delete_org_unit", { org_unit_id: orgUnitId });
if (!result.success) return result;
for (const path of ORG_PFADE) revalidatePath(path);
return result;
}

View File

@@ -2,7 +2,12 @@
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { kontierungZum, kostenstellenAbfrage, type KontierungsZeile } from "@/lib/cost-centers";
import { withUser } from "@/lib/db";
import { jsonArrayFrom } from "@/lib/db/json";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
import { fmtName, todayIso } from "@/lib/format";
import type { PlanstelleZumAendern } from "@/lib/positions";
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
@@ -82,3 +87,88 @@ export async function setPositionCostCenter(payload: {
}): Promise<ActionResult> {
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
}
export type PlanstelleDerEinheit = PlanstelleZumAendern & {
/** Wer am Stichtag darauf sitzt; null heisst unbesetzt. */
besetztVon: string | null;
};
/**
* Die Planstellen einer Organisationseinheit — **auch die besetzten**.
*
* Gelesen, nicht geschrieben, und erst beim Öffnen der Einheit: der Bestand
* zählt 788 Planstellen, und sie alle in jede Zeichnung des Organigramms zu
* legen hiesse, für achtzig Einheiten zu laden, was man für eine braucht.
*
* Der Grund, warum es diese Abfrage überhaupt gibt: die Seite Positionen zeigt
* nur unbesetzte Stellen, und der Änderungsdialog war allein von dort aus
* erreichbar. Die Kontierung einer besetzten Planstelle liess sich damit
* nirgends ändern.
*
* Kein Schutzgatter im Anwendungscode: die Zeilenschutz-Regeln geben einer
* Person ohne HR-Recht nichts zurück, und das ist die Grenze, die zählt.
*/
export async function ladePlanstellenDerEinheit(orgUnitId: string): Promise<{
planstellen: PlanstelleDerEinheit[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server: der Dialog rechnet sonst mit der Zone des Browsers. */
heute: string;
}> {
const heute = todayIso();
const g = await withUser(await currentUserId(), (tx) =>
tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
.where("p.org_unit_id", "=", orgUnitId)
.orderBy("p.position_number")
).as("stellen"),
jsonArrayFrom(
eb
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.innerJoin("employees as e", "e.id", "a.employee_id")
.select(["a.position_id", "e.first_name", "e.last_name"])
.where("p.org_unit_id", "=", orgUnitId)
.where((x) => x.or([x("a.valid_to", "is", null), x("a.valid_to", ">", heute)]))
).as("besetzungen"),
// Nicht kontierungenAbfrage(): die filtert über eine Liste von
// Planstellen, die hier erst aus der ersten Teilabfrage käme — und das
// wäre eine zweite Rundreise für dieselbe Auskunft.
jsonArrayFrom(
eb
.selectFrom("position_cost_centers as z")
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
.innerJoin("om_positions as p", "p.id", "z.position_id")
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
.where("p.org_unit_id", "=", orgUnitId)
.orderBy("z.valid_from")
).as("kontierungen"),
jsonArrayFrom(kostenstellenAbfrage(eb, heute)).as("kostenstellen"),
])
.executeTakeFirstOrThrow()
);
const kontierung = kontierungZum(g.kontierungen as KontierungsZeile[], heute);
const inhaber = new Map(g.besetzungen.map((b) => [b.position_id, fmtName(b.first_name, b.last_name)]));
return {
planstellen: g.stellen.map((p) => ({
id: p.id,
position_number: p.position_number,
title: p.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
valid_to: p.valid_to,
kostenstelle: kontierung.get(p.id) ?? null,
besetztVon: inhaber.get(p.id) ?? null,
})),
kostenstellen: g.kostenstellen,
heute,
};
}

View File

@@ -8,7 +8,7 @@ import { StatusChip } from "@/components/ui/StatusChip";
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 { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search";
import {
SORTIERFELDER,
naechsteRichtung,
@@ -150,7 +150,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
.innerJoin("om_positions as p", "p.id", "a.position_id")
.select("a.id")
.whereRef("a.employee_id", "=", "employees.id")
.where("a.valid_to", "is", null)
// Laufend **oder noch bevorstehend**: `valid_to is null` allein
// liess jede Person mit vorgemerktem Austritt aus der Einheit
// verschwinden — terminate_employee setzt das Ende schon beim
// Erfassen, Monate vor dem Tag. Gemeldet im Test vom 29.09.
// (C.09). Das Ende ist ausschliessend, deshalb `>` und nicht
// `>=`. Kein `valid_from <= heute`: ein geplanter Eintritt
// gehört in die Liste, sonst fiele er aus dem Einheitenfilter,
// obwohl der Status „Geplant" ihn ausdrücklich führt.
.where((e) => e.or([e("a.valid_to", "is", null), e("a.valid_to", ">", today)]))
.where("p.org_unit_id", "in", units)
)
);
@@ -190,9 +198,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
// Die Trennzeichen stehen in lib/employee-search.ts, weil die
// Eingabe an denselben zerlegt werden muss — standen sie nur hier,
// fand „Müller-Weiß" nichts (C.08).
const heuhaufen = nurNamen
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
? sql<string>`translate(lower(first_name || ' ' || last_name), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`;
q = q.where((eb) =>
eb.and(
// Als Parameter gebunden, nicht in die Abfrage geschrieben.

View File

@@ -22,7 +22,17 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
const { org, units } = await withUser(await currentUserId(), async (tx) => {
const [org, units] = await Promise.all([
loadOrgAsOf(tx, asOf),
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
tx
.selectFrom("org_units")
// valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie,
// der Druck braucht sie nicht.
.select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"])
// Zum Stichtag — sonst zeigt die Struktursicht zum 01.01.2020
// Einheiten, die es damals nicht gab (D.06).
.where("valid_from", "<=", asOf)
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.orderBy("org_number")
.execute(),
]);
return { org, units };
});

View File

@@ -70,7 +70,10 @@ export default async function DashboardPage({
const today = todayIso();
const year = today.slice(0, 4);
const yearStart = `${year}-01-01`;
const yearEnd = `${year}-12-31`;
// Das obere Ende des YTD-Fensters ist **heute**. Hier stand der 31.12., also
// das ganze Kalenderjahr — womit die Kacheln auch zählten, was erst
// bevorsteht (B.02).
const ytdBis = today;
// Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen
// wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90
@@ -103,7 +106,7 @@ export default async function DashboardPage({
upcomingReturns,
upcomingNotes,
history,
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, ytdBis, bisIso, arten }));
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
// active, and is counted by its own tile instead. FTE follows the same
// set: Karenz contributes no capacity, so including it would overstate
@@ -195,8 +198,13 @@ export default async function DashboardPage({
// wer heute arbeitet, hier, mit wem ein Vertrag läuft —
// Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10,
// addieren musste man selbst.
// Die Namen der beiden stehen so fest: Max am 30.09. — „Es gibt zwei
// Headcounts: Headcount Aktiv (nur Aktive) und Headcount Aktives
// Dienstverhältnis (Aktiv + Langzeitabwesenheit)". Vorher hiess nur die
// rechte Kachel „(HC)", und im Bericht stand daneben „Headcount" für
// eine dritte Zahl — dasselbe Wort für Verschiedenes (N.02).
{
label: "Aktives Dienstverhältnis",
label: "Headcount Aktives Dienstverhältnis",
value: activeCount + karenzCount,
tone: "default",
// Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter
@@ -204,9 +212,7 @@ export default async function DashboardPage({
// Next daraus ein Array, und die Seite scheiterte an `.split(",")`.
href: "/employees?status=Aktiv,Karenz",
},
// „(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: "Headcount Aktiv", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
{ label: "Langzeitabwesende", 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
@@ -225,16 +231,18 @@ export default async function DashboardPage({
kacheln: [
{
// YTD: seit Jahresbeginn bis heute, nicht das ganze Kalenderjahr.
// Der Verweis trägt denselben Zeitraum — sonst zeigte der Bericht
// eine andere Zahl als die Kachel, über die man ihn geöffnet hat.
label: "Eintritte (YTD)",
value: hiresYtd,
tone: "success",
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${ytdBis}`,
},
{
label: "Austritte (YTD)",
value: exitsYtd,
tone: "danger",
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${ytdBis}`,
},
],
},

View File

@@ -157,7 +157,7 @@ function BrandPanel() {
<div className="relative max-w-md">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
Die Organisation, so wie sie heute wirklich aussieht.
Alles im Blick. Alles Manner.
</p>
<p className="mt-4 text-sm leading-relaxed text-brand-700">
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen

View File

@@ -21,14 +21,14 @@ export async function GET(request: NextRequest) {
const stichtag = asOf ?? todayIso();
const { zeilen } = await withUser(gate.userId, async (tx) => {
const { rows, managerNummer } = await ladeExportMitarbeiter(tx, { asOf, filters });
const { rows, managerKennung } = await ladeExportMitarbeiter(tx, { asOf, filters });
// Die eine Angabe, die die gemeinsame Auswahl nicht mitbringt: die
// Kostenstelle hängt an der Planstelle, nicht an der Person. Erst jetzt,
// weil erst jetzt feststeht, um welche Planstellen es geht.
const positionIds = [...new Set(rows.map((r) => r.position_id).filter((id): id is string => Boolean(id)))];
const kontext: CornerstoneKontext = {
managerNummer,
managerKennung,
kostenstelle: await loadKontierungen(tx, { asOf: stichtag, positionIds }),
};

View File

@@ -53,6 +53,7 @@ function employeeExportColumns(
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "Private E-Mail", get: (e) => e.email },
{ header: "Firmen-E-Mail", get: (e) => e.company_email },
{ header: "Cornerstone-ID", get: (e) => e.cornerstone_id },
{ header: "Private Telefonnummer", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
@@ -96,7 +97,7 @@ function employeeExportColumns(
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade },
{ header: "Hay-Grade", get: (e) => e.paygrade },
{ header: "Herkunft", get: (e) => e.source },
// The export shows the display name, not the raw enum value — a payroll
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit

View File

@@ -0,0 +1,45 @@
import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
import { baueLolyoZeile, LOLYO_CSV, lolyoSpalten } from "@/lib/lolyo";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
// Der Export für die Mitarbeiter-App LOLYO — Spalten und feste Werte stehen in
// lib/lolyo.ts.
//
// Dieselben Personen wie im vollständigen Export daneben, mit denselben Filtern
// (lib/export-auswahl.ts). Wer nur einen Bereich oder nur die Aktiven anlegen
// will, stellt das auf der Berichtsseite ein, bevor er exportiert.
/**
* Das gemeinsame Startpasswort, mit dem die Konten in LOLYO angelegt werden.
*
* Steht bewusst **hier** und nicht in lib/lolyo.ts: eine Route ist serverseitig
* und gelangt nie in ein Bündel, das der Browser lädt. Eine Konstante in einer
* lib-Datei wäre nur so lange sicher, wie niemand sie aus einem Client-Bauteil
* heraus einbindet — und das sähe man dem Bündel nicht an. Dieselbe Überlegung
* steht in lib/passwort.ts.
*
* Es ist dasselbe Passwort wie in Alpenwerk, so bestellt. Damit gilt hier auch
* dasselbe: es ist allen gemeinsam und damit bekannt, und es trägt so lange,
* bis die Person es wechselt. Die erzeugte Datei ist deshalb kein Anhang, den
* man weiterleitet.
*/
const STARTPASSWORT = "Initialpasswort2026!";
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
const { rows } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters }));
const zeilen = rows.map((r) => baueLolyoZeile(r, STARTPASSWORT));
const spalten = lolyoSpalten();
const filename = exportFilename("lolyo-export", format);
const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "LOLYO") : toCsv(zeilen, spalten, LOLYO_CSV);
// Derselbe Umweg über Blob wie im vollständigen Export (TS#59417).
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}

View File

@@ -217,20 +217,44 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
{/* Der `key` an jedem Panel ist kein Feinschliff, sondern der Unterschied
zwischen „zeigt Altes" und „schreibt Altes zurück".
Die Panels bleiben eingebunden, damit ihr Ein- und Ausfahren laufen
kann. Sie belegen ihre Felder aber mit useState(employee.…) vor, und
das läuft nur beim ersten Aufbau. Nach einer Beförderung bringt
router.refresh() zwar die frische Akte herein — der Zustand im Panel
bleibt der von vorhin. Beim nächsten Speichern geht er als Ganzes an
change_employee_data, und der eben gesetzte Hay-Grade steht wieder
auf dem alten Wert. Gemeldet im Test vom 29.09. (H.05).
employees.updated_at wechselt bei jeder Änderung an der Zeile
(Trigger trg_employees_touch_updated_at), also genau dann, wenn die
Vorbelegung neu zu lesen ist — und sonst nie. Der Wiedereintritts-
Assistent darunter macht es seit jeher so. */}
<TransferPanel
key={`transfer-${employee.updated_at}`}
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
<PromotePanel
key={`promote-${employee.updated_at}`}
open={panel === "promote"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} status={status} />
<KarenzPanel
key={`karenz-${employee.updated_at}`}
open={panel === "karenz"}
onClose={() => setPanel(null)}
employee={employee}
status={status}
/>
<DatenAendernPanel
key={`daten-${employee.updated_at}`}
open={panel === "daten"}
onClose={() => setPanel(null)}
employee={employee}
@@ -238,6 +262,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
locationCountry={location?.country}
/>
<TerminatePanel
key={`terminate-${employee.updated_at}`}
open={panel === "terminate"}
onClose={() => setPanel(null)}
employee={employee}

View File

@@ -13,13 +13,14 @@ import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
import { HAY_GRADES } from "@/lib/hay-grade";
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 { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/types";
import type { ContractType, Database, EmploymentType, GenderType, PaygradeType } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
@@ -66,6 +67,7 @@ export function DatenAendernPanel({
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email ?? "");
const [companyEmail, setCompanyEmail] = useState(employee.company_email ?? "");
const [cornerstoneId, setCornerstoneId] = useState(employee.cornerstone_id ?? "");
const [phone, setPhone] = useState(employee.phone ?? "");
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
@@ -78,6 +80,7 @@ export function DatenAendernPanel({
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
const [role, setRole] = useState<RoleEmploymentValue>({
workerType: employee.worker_type ?? "Angestellte:r",
@@ -163,6 +166,7 @@ export function DatenAendernPanel({
// Leerzeichen stehen geblieben ist, als Inhalt in der Spalte an.
email: email.trim(),
company_email: companyEmail.trim(),
cornerstone_id: cornerstoneId.trim(),
phone: phone.trim(),
// Der Notfallkontakt läuft über seinen eigenen Abschnitt weiter unten
// — wie die Angehörigen. Stünde er zusätzlich hier, schrieben zwei
@@ -174,6 +178,7 @@ export function DatenAendernPanel({
weekly_hours: Number(weeklyHours),
contract_type: contractType,
contract_end_date: contractType === "befristet" ? contractEndDate : "",
paygrade,
},
role: {
worker_type: role.workerType,
@@ -309,6 +314,7 @@ export function DatenAendernPanel({
</Field>
<TextField label="E-Mail (privat)" dense type="email" value={email} onChange={setEmail} />
<TextField label="Firmen-E-Mail" dense type="email" value={companyEmail} onChange={setCompanyEmail} />
<TextField label="Cornerstone-ID" dense value={cornerstoneId} onChange={setCornerstoneId} />
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
</div>
@@ -369,6 +375,18 @@ export function DatenAendernPanel({
{contractType === "befristet" && (
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
)}
{/* Auch über die Beförderung zu setzen — und trotzdem hier. Eine
Umstufung ist nicht immer eine Beförderung: „da stand von
Anfang an die falsche Stufe" ist eine Richtigstellung, und die
soll keine Planstelle wechseln und kein Ereignis „Beförderung"
in der Historie hinterlassen. */}
<SelectField
label="Hay-Grade"
dense
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={HAY_GRADES}
/>
</div>
</div>

View File

@@ -7,21 +7,13 @@ 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 { HAY_GRADES } from "@/lib/hay-grade";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database, PaygradeType } from "@/lib/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const PAYGRADES: { value: PaygradeType; label: string }[] = [
{ value: "A", label: "A – Einstieg" },
{ value: "B", label: "B – Qualifiziert" },
{ value: "C", label: "C – Erfahren" },
{ value: "D", label: "D – Spezialist:in" },
{ value: "E", label: "E – Teamleitung" },
{ value: "F", label: "F – Bereichsleitung / GF" },
];
// Eine Beförderung kann auf derselben Planstelle stattfinden oder auf eine
// andere führen. Beides kommt vor: jemand wächst auf seiner Stelle, oder er
// rückt auf eine höhere — und dann ist die Zielstelle dieselbe Auswahl wie
@@ -173,10 +165,10 @@ export function PromotePanel({
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
<SelectField
label="Verwendungsgruppe"
label="Hay-Grade"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={PAYGRADES}
options={HAY_GRADES}
/>
</div>
</SlideOver>

View File

@@ -54,6 +54,7 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
["Land", employee.address_country ?? "–"],
["Private E-Mail", employee.email ?? "–"],
["Firmen-E-Mail", employee.company_email ?? "–"],
["Cornerstone-ID", employee.cornerstone_id ?? "–"],
["Private Telefonnummer", employee.phone ?? "–"],
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man

View File

@@ -1,17 +1,48 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate } from "@/lib/format";
import { hayGradeLabel } from "@/lib/hay-grade";
import type { Database } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const PAYGRADE_LABELS: Record<string, string> = {
A: "A – Einstieg",
B: "B – Qualifiziert",
C: "C – Erfahren",
D: "D – Spezialist:in",
E: "E – Teamleitung",
F: "F – Bereichsleitung / GF",
};
/**
* Der Kündigungsschutz mit Grund und Zeitraum.
*
* Hier stand nur „bis TT.MM.JJJJ". Erfasst werden aber Personenkreis, Beginn
* und Ende — und der Personenkreis ist die eigentliche Auskunft: „bis 2030"
* sagt nicht, warum. Gemeldet am 29.09. von Lara und im Testprotokoll (H.09).
*
* Die Wortwahl für den einfachsten Fall bleibt: steht nur das Kennzeichen da,
* heisst es weiter „Ja (Ende offen)".
*/
function kuendigungsschutzText(e: EmployeeRow): string {
if (!e.has_kuendigungsschutz) return "Nein";
const teile = [
e.kuendigungsschutz_grund,
e.kuendigungsschutz_ab ? `ab ${fmtDate(e.kuendigungsschutz_ab)}` : null,
e.kuendigungsschutz_bis ? `bis ${fmtDate(e.kuendigungsschutz_bis)}` : null,
].filter((t): t is string => Boolean(t));
if (teile.length === 0) return "Ja (Ende offen)";
return e.kuendigungsschutz_bis ? teile.join(" · ") : `${teile.join(" · ")} · Ende offen`;
}
/**
* Die Begünstigung als eigene Zeile, nicht an den Kündigungsschutz gehängt.
*
* Sie führt zwar oft zu besonderem Kündigungsschutz, ist aber ein eigener
* Bescheid — und der Grad gehört zur Behinderung, nicht zum Schutz.
*/
function behinderungText(e: EmployeeRow): string {
if (!e.ist_beguenstigt_behindert) return "Nein";
const teile = [
e.behinderung_grad !== null ? `${e.behinderung_grad} %` : null,
e.behinderung_ab ? `Bescheid ab ${fmtDate(e.behinderung_ab)}` : null,
e.behinderung_bis ? `bis ${fmtDate(e.behinderung_bis)}` : null,
].filter((t): t is string => Boolean(t));
return teile.length > 0 ? teile.join(" · ") : "Ja";
}
export function VertragTab({ employee }: { employee: EmployeeRow }) {
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
@@ -26,7 +57,7 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["Beschäftigungsausmaß", employee.employment_type],
["Wochenstunden", `${employee.weekly_hours} h`],
["Urlaubsanspruch", "25 Tage"],
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Hay-Grade", hayGradeLabel(employee.paygrade)],
["Beschäftigtengruppe", employee.worker_type ?? "–"],
["Arbeitstage", employee.work_days?.join(", ") || "–"],
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
@@ -47,14 +78,17 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
: `${employee.teilzeit_art} (Ende offen)`
: "–",
],
[
"Besonderer Kündigungsschutz",
employee.has_kuendigungsschutz
? employee.kuendigungsschutz_bis
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
: "Ja (Ende offen)"
: "Nein",
],
// Grund und Zeitraum stehen dabei, nicht nur das Enddatum. Gemeldet am
// 29.09.: erfasst werden sieben Felder — Personenkreis, Beginn, Ende,
// Behinderung mit Grad und Bescheidzeitraum —, sichtbar war in der Akte
// nur „bis TT.MM.JJJJ". Wer den Grund brauchte, fand ihn nur in der
// Historie oder über einen Bericht.
["Besonderer Kündigungsschutz", kuendigungsschutzText(employee)],
// Eigene Zeile und nicht an den Kündigungsschutz gehängt: die Begünstigung
// ist ein Bescheid für sich. Sie führt zwar oft zu besonderem
// Kündigungsschutz, ist aber nicht dasselbe, und der Grad gehört zur
// Behinderung, nicht zum Schutz.
["Begünstigt behindert", behinderungText(employee)],
];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -149,6 +149,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
sv_nummer: draft.svNummer || undefined,
email: draft.email.trim() || undefined,
company_email: draft.companyEmail.trim() || undefined,
cornerstone_id: draft.cornerstoneId.trim() || undefined,
phone: draft.phone || undefined,
position_id: draft.positionId,
location_id: draft.locationId,

View File

@@ -125,6 +125,7 @@ export function RehireWizard({ open, onClose, employee, openPositions, locations
sv_nummer: draft.svNummer.trim(),
email: draft.email.trim(),
company_email: draft.companyEmail.trim(),
cornerstone_id: draft.cornerstoneId.trim(),
phone: draft.phone.trim(),
location_id: draft.locationId,
// Anschrift, Staatsbürgerschaft und Aufenthaltstitel stehen bewusst

View File

@@ -74,6 +74,12 @@ export function StepPerson({ draft, update, locations, onNummerBefund, nummerGes
value={draft.companyEmail}
onChange={(companyEmail) => update({ companyEmail })}
/>
<TextField
label="Cornerstone-ID"
value={draft.cornerstoneId}
onChange={(cornerstoneId) => update({ cornerstoneId })}
hint="Freiwillig. Füllt im Cornerstone-Export „User ID“ und „Username“."
/>
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
</div>
<SelectField

View File

@@ -1,17 +1,9 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate, fmtFullName } from "@/lib/format";
import { hayGradeLabel } from "@/lib/hay-grade";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
const PAYGRADE_LABELS: Record<string, string> = {
A: "A – Einstieg",
B: "B – Qualifiziert",
C: "C – Erfahren",
D: "D – Spezialist:in",
E: "E – Teamleitung",
F: "F – Bereichsleitung / GF",
};
type StepSummaryProps = {
draft: HireDraftData;
selectedPosition: OpenPositionResolved | null;
@@ -49,7 +41,7 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
["Eintrittsdatum", fmtDate(draft.entryDate)],
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
["Hay-Grade", hayGradeLabel(draft.paygrade)],
["Beschäftigtengruppe", draft.workerType],
["Kollektivvertrag", draft.collectiveAgreement],
["Arbeitstage", draft.workDays.join(", ") || "–"],

View File

@@ -1,17 +1,9 @@
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
import { SelectField, TextField } from "@/components/ui/Field";
import { HAY_GRADES } from "@/lib/hay-grade";
import type { PaygradeType } from "@/lib/types";
import type { HireDraftData } from "./types";
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [
{ value: "A", label: "A – Einstieg", description: "Berufseinsteiger:innen ohne einschlägige Erfahrung" },
{ value: "B", label: "B – Qualifiziert", description: "Fachkräfte mit abgeschlossener Ausbildung" },
{ value: "C", label: "C – Erfahren", description: "Mehrjährige einschlägige Berufserfahrung" },
{ value: "D", label: "D – Spezialist:in", description: "Vertiefte Fachexpertise" },
{ value: "E", label: "E – Teamleitung", description: "Fachliche und disziplinäre Führung eines Teams" },
{ value: "F", label: "F – Bereichsleitung / GF", description: "Führung eines Bereichs bzw. Geschäftsführung" },
];
export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial<HireDraftData>) => void }) {
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
update({
@@ -63,12 +55,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
/>
</div>
<SelectField
label="Paygrade"
label="Hay-Grade"
required
value={draft.paygrade}
onChange={(v) => update({ paygrade: v as PaygradeType })}
options={PAYGRADES}
hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description}
options={HAY_GRADES}
/>
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>

View File

@@ -1,3 +1,4 @@
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, Mitarbeiterart, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types";
@@ -35,6 +36,8 @@ export type HireDraftData = {
svNummer: string;
email: string;
companyEmail: string;
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
cornerstoneId: string;
phone: string;
locationId: string;
positionId: string;
@@ -84,6 +87,7 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
svNummer: "",
email: "",
companyEmail: "",
cornerstoneId: "",
phone: "",
locationId: "",
positionId: "",
@@ -93,7 +97,7 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
contractEndDate: "",
employmentType: "Vollzeit",
weeklyHours: "38.5",
paygrade: "B",
paygrade: HAY_GRADE_STANDARD,
workerType: "Angestellte:r",
collectiveAgreement: "Handel",
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],

View File

@@ -2,8 +2,9 @@
import { CalendarClock } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { FILTER_SELECT_CLASS, istMeldbaresDatum } from "@/components/ui/Field";
import { fmtDate } from "@/lib/format";
type AsOfPickerProps = {
@@ -27,6 +28,14 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
}
// Angeglichen während des Renderns, nicht in einem Effekt — siehe DateField.
const [entwurf, setEntwurf] = useState(asOf);
const [zuletzt, setZuletzt] = useState(asOf);
if (asOf !== zuletzt) {
setZuletzt(asOf);
setEntwurf(asOf);
}
const isToday = asOf === today;
const isFuture = asOf > today;
// Assignments only started being recorded when the history table was
@@ -41,11 +50,23 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
<CalendarClock className="h-4 w-4 text-ink-muted" />
Stichtag
</label>
{/* Der Tippstand bleibt hier, gemeldet wird nur ein vollständiges
Datum: jede Änderung ist eine Navigation, und beim Tippen der
Jahreszahl entstehen unterwegs 0002, 0020 und 0202 — jede davon
lud die Seite neu und setzte das Feld mitten im Tippen zurück.
Dieselbe Regel wie in DateField, dort steht auch der lange Grund. */}
<input
id="orgchart-asof"
type="date"
value={asOf}
onChange={(e) => setAsOf(e.target.value || undefined)}
value={entwurf}
onChange={(e) => {
const wert = e.target.value;
setEntwurf(wert);
if (istMeldbaresDatum(wert) && wert !== asOf) setAsOf(wert || undefined);
}}
onBlur={() => {
if (entwurf !== asOf && istMeldbaresDatum(entwurf)) setAsOf(entwurf || undefined);
}}
className={FILTER_SELECT_CLASS}
/>
{!isToday && (

View File

@@ -0,0 +1,126 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createOrgUnit } from "@/actions/org";
import { Button } from "@/components/ui/Button";
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 { naechsteOrgnummer } from "@/lib/org-nummer";
import type { OrgUnitNode } from "./types";
type Art = "Bereich" | "Abteilung" | "Team";
// „Gesellschaft“ fehlt in der Auswahl mit Absicht: es gibt genau eine, sie ist
// die Wurzel, und unter einer anderen Einheit wäre sie ein Etikett, das seiner
// Stelle im Baum widerspricht. Die Datenbankfunktion weist es ohnehin ab — die
// fehlende Zeile erspart den Weg dorthin.
const ARTEN: { value: Art; label: string }[] = [
{ value: "Bereich", label: "Bereich" },
{ value: "Abteilung", label: "Abteilung" },
{ value: "Team", label: "Team" },
];
/**
* Die Art, die unter einer Einheit am wahrscheinlichsten ist: eine Stufe
* feiner als die darüber. Nur eine Vorbelegung — die Tiefe des Baums ist frei,
* und eine Abteilung unter einer Abteilung ist erlaubt.
*/
function vorschlagArt(eltern: OrgUnitNode["unit_type"]): Art {
if (eltern === "Gesellschaft") return "Bereich";
if (eltern === "Bereich") return "Abteilung";
return "Team";
}
export function CreateOrgUnitModal({
parent,
units,
onClose,
}: {
parent: OrgUnitNode;
units: OrgUnitNode[];
onClose: () => void;
}) {
const { showToast } = useToast();
const router = useRouter();
const [orgNumber, setOrgNumber] = useState(() => naechsteOrgnummer(units.map((u) => u.org_number)) ?? "");
const [name, setName] = useState("");
const [art, setArt] = useState<Art>(() => vorschlagArt(parent.unit_type));
// **Nicht** der Stichtag der Ansicht. Wer sich das Organigramm zum letzten
// Jahresende ansieht und hier auf Plus drückt, will in aller Regel eine
// Einheit von heute anlegen und keine rückdatierte. Ein stillschweigend
// übernommenes Datum aus der Ansicht wäre die Art von Vorbelegung, die
// niemand liest und die hinterher niemand erklären kann.
const [validFrom, setValidFrom] = useState(todayIso);
const [leitung, setLeitung] = useState("");
const [pending, setPending] = useState(false);
async function handleSubmit() {
if (!orgNumber.trim() || !name.trim() || !validFrom) {
showToast("Orgnummer, Bezeichnung und Gültigkeitsbeginn sind Pflicht.", "error");
return;
}
setPending(true);
const result = await createOrgUnit({
parent_id: parent.id,
org_number: orgNumber.trim(),
name: name.trim(),
unit_type: art,
valid_from: validFrom,
leitung_taetigkeit: leitung.trim() || undefined,
});
setPending(false);
if (result.success) {
showToast(leitung.trim() ? "Einheit samt Leitungsplanstelle angelegt." : "Einheit angelegt.");
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler beim Anlegen.", "error");
}
}
return (
<Modal
open
onClose={onClose}
title="Einheit anlegen"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Anlegen
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
Unter <span className="font-semibold text-ink-body">{parent.org_number} · {parent.name}</span>.
Bestehende Einheiten lassen sich nicht hierher verschieben — die neue entsteht leer.
</p>
<TextField
label="Orgnummer"
required
value={orgNumber}
onChange={setOrgNumber}
hint="Vorgeschlagen wird die nächste freie Nummer der bestehenden Reihe. Sie muss zum führenden System passen."
/>
<TextField label="Bezeichnung" required value={name} onChange={setName} />
<SelectField label="Art" required value={art} onChange={(v) => setArt(v as Art)} options={ARTEN} />
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
<TextField
label="Leitungsplanstelle (optional)"
value={leitung}
onChange={setLeitung}
hint="Tätigkeit der Leitung, z. B. „Abteilungsleitung Einkauf“. Leer lassen, dann entsteht die Einheit ohne Leitung und ist im Organigramm als vakant zu sehen."
/>
</div>
</Modal>
);
}

View File

@@ -39,6 +39,10 @@ export type GraphOrgChartProps = {
isExpanded: (id: string) => boolean;
onToggle: (id: string) => void;
matchedIds?: Set<string> | null;
/** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
onSelectUnit?: (unitId: string) => void;
};
export function GraphOrgChart(props: GraphOrgChartProps) {
@@ -49,7 +53,15 @@ export function GraphOrgChart(props: GraphOrgChartProps) {
);
}
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) {
function GraphOrgChartInner({
tree,
isExpanded,
onToggle,
matchedIds,
onAddUnit,
onDeleteUnit,
onSelectUnit,
}: GraphOrgChartProps) {
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
const { rfNodes, rfEdges } = useMemo(() => {
@@ -65,6 +77,9 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
hasChildren: n.children.length > 0,
childCount: n.children.length,
onToggle,
onAddUnit,
onDeleteUnit,
onSelectUnit,
},
}));
const rfEdges: Edge[] = visibleEdges.map((e) => ({
@@ -76,7 +91,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit]);
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);

View File

@@ -1,7 +1,7 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
import { Building2, ChevronDown, Plus, Trash2, UserRound } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import { Avatar } from "@/components/ui/Avatar";
@@ -14,6 +14,11 @@ export type OrgChartNodeData = {
hasChildren: boolean;
childCount: number;
onToggle: (id: string) => void;
/** Nur in der Struktursicht gesetzt — ohne sie bleibt die Karte unverändert. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
/** Klick auf die Karte einer Einheit — öffnet ihre Angaben. */
onSelectUnit?: (unitId: string) => void;
};
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
@@ -42,8 +47,8 @@ const KIND_SHELL: Record<ChartNodeKind, string> = {
// React Flow re-renders node components on every pan/zoom frame — memo is
// required, not just tidy, to keep that smooth at a few hundred nodes.
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode;
const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit, onSelectUnit } = data;
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor, unitId } = chartNode;
const isMatch = chartNode.matched ?? false;
const { width, height } = NODE_DIMENSIONS[kind];
@@ -107,6 +112,50 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
>
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
{/* Erscheint erst beim Überfahren der Karte: auf achtzig Einheiten wären
achtzig ständig sichtbare Pluszeichen ein Muster und kein Angebot.
Auf Geräten ohne Zeigegerät bleibt er stehen — dort gibt es kein
Überfahren, und unsichtbar hiesse dann unerreichbar. */}
{unitId && (onAddUnit || onDeleteUnit) && (
<span
className="nodrag nopan absolute -right-2.5 -top-2.5 z-10 flex items-center gap-1
opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100
[@media(hover:none)]:opacity-100"
>
{/* Der Papierkorb nur am Blatt. Eine Einheit mit Kindern lässt sich
ohnehin nicht entfernen — der Knopf wäre dort ein Angebot, das
beim Klick eine Absage erteilt, und das ist schlechter als kein
Knopf. Die Datenbank prüft es trotzdem: sie sieht auch
geschlossene Planstellen, die hier gar nicht gezeichnet sind. */}
{onDeleteUnit && !hasChildren && (
<button
type="button"
onClick={() => onDeleteUnit(unitId)}
aria-label={`Einheit ${label} entfernen`}
title="Einheit entfernen"
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
transition-colors hover:border-danger-solid hover:bg-danger-solid hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{onAddUnit && (
<button
type="button"
onClick={() => onAddUnit(unitId)}
aria-label={`Einheit unter ${label} anlegen`}
title="Untergeordnete Einheit anlegen"
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</span>
)}
{/* Accent stripe, inset so it follows the card's rounded corner. */}
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
@@ -114,6 +163,18 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
{content}
</Link>
) : unitId && onSelectUnit ? (
// Die ganze Karte, nicht ein Symbol darin: bei achtzig Einheiten ist
// die Fläche das Ziel, das man trifft, ohne hinzusehen.
<button
type="button"
onClick={() => onSelectUnit(unitId)}
aria-label={`Angaben zu ${label}`}
className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3 text-left
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{content}
</button>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
)}

View File

@@ -0,0 +1,241 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { ladePlanstellenDerEinheit, type PlanstelleDerEinheit } from "@/actions/positions";
import { EditPositionModal } from "@/components/positions/EditPositionModal";
import type { UnitOption } from "@/components/positions/CreatePositionModal";
import { Button } from "@/components/ui/Button";
import { SlideOver } from "@/components/ui/SlideOver";
import { fmtDate, fmtName } from "@/lib/format";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
// Was an einer Organisationseinheit steht — beim Klick auf ihre Karte.
//
// Alles hier kommt aus den Daten, die die Struktursicht ohnehin geladen hat:
// Einheiten, Personen und Vakanzen zum eingestellten Stichtag. Kein zusätzlicher
// Gang zur Datenbank, und damit auch keine zweite Wahrheit — was die Karte
// zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist.
function Zeile({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 border-b border-border-subtle py-1.5 last:border-0">
<span className="shrink-0 text-xs text-ink-muted">{label}</span>
<span className="text-right text-sm text-ink-body">{children}</span>
</div>
);
}
export function OrgUnitPanel({
unit,
units,
unitOptions,
employees,
vacancies,
onAddUnit,
onDeleteUnit,
onClose,
}: {
unit: OrgUnitNode;
units: OrgUnitNode[];
/** Für den Änderungsdialog: derselbe Baum, nur in seiner Form. */
unitOptions: UnitOption[];
employees: OrgEmployee[];
vacancies: OrgVacancy[];
onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void;
onClose: () => void;
}) {
// Erst beim Öffnen geladen, und nur für diese eine Einheit — siehe den Grund
// bei ladePlanstellenDerEinheit.
const [stellen, setStellen] = useState<PlanstelleDerEinheit[] | null>(null);
const [kostenstellen, setKostenstellen] = useState<{ id: string; code: string; name: string }[]>([]);
const [heute, setHeute] = useState("");
const [bearbeitet, setBearbeitet] = useState<PlanstelleDerEinheit | null>(null);
const laden = useCallback(async () => {
const daten = await ladePlanstellenDerEinheit(unit.id);
setStellen(daten.planstellen);
setKostenstellen(daten.kostenstellen);
setHeute(daten.heute);
}, [unit.id]);
useEffect(() => {
let verworfen = false;
// Der Nachtrag einer Antwort, die zu einer anderen Einheit gehört, wäre
// hier besonders tückisch: die Leiste sähe richtig aus und zeigte fremde
// Planstellen.
void ladePlanstellenDerEinheit(unit.id).then((daten) => {
if (verworfen) return;
setStellen(daten.planstellen);
setKostenstellen(daten.kostenstellen);
setHeute(daten.heute);
});
return () => {
verworfen = true;
};
}, [unit.id]);
const kinder = units.filter((u) => u.parent_id === unit.id);
// Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist
// heute nicht herstellbar, aber diese Schleife wäre die erste Stelle, die
// ihn ausbaden müsste — und ein hängender Browser sagt niemandem, woran es
// liegt.
const kette: OrgUnitNode[] = [];
const gesehen = new Set<string>();
let lauf = unit.parent_id ? units.find((u) => u.id === unit.parent_id) : undefined;
while (lauf && !gesehen.has(lauf.id)) {
gesehen.add(lauf.id);
kette.unshift(lauf);
lauf = lauf.parent_id ? units.find((u) => u.id === lauf!.parent_id) : undefined;
}
const eigene = employees.filter((e) => e.org_unit_id === unit.id);
const leitung = eigene.find((e) => e.is_chief);
const belegschaft = eigene.filter((e) => !e.is_chief);
const offene = vacancies.filter((v) => v.org_unit_id === unit.id);
// Alles darunter, nicht nur die direkten Kinder — die Frage hinter „wie
// gross ist dieser Bereich" meint immer den ganzen Teilbaum.
const teilbaum = new Set<string>([unit.id]);
for (let gewachsen = true; gewachsen; ) {
gewachsen = false;
for (const u of units) {
if (u.parent_id && teilbaum.has(u.parent_id) && !teilbaum.has(u.id)) {
teilbaum.add(u.id);
gewachsen = true;
}
}
}
const imTeilbaum = employees.filter((e) => teilbaum.has(e.org_unit_id)).length;
// Dieselbe Bedingung wie am Papierkorb auf der Karte: nur ein Blatt ohne
// Planstellen lässt sich entfernen. Die Datenbank prüft strenger — sie sieht
// auch geschlossene Planstellen, die hier nicht gezeichnet sind.
const entfernbar = kinder.length === 0 && eigene.length === 0 && offene.length === 0 && unit.parent_id !== null;
return (
<SlideOver open onClose={onClose} title={unit.name} subtitle={`${unit.org_number} · ${unit.unit_type}`}>
<div className="flex flex-col gap-5">
<div>
<Zeile label="Orgnummer">{unit.org_number}</Zeile>
<Zeile label="Art">{unit.unit_type}</Zeile>
<Zeile label="Übergeordnet">
{kette.length > 0 ? kette.map((u) => u.name).join(" › ") : "—"}
</Zeile>
<Zeile label="Gültig ab">{unit.valid_from ? fmtDate(unit.valid_from) : "—"}</Zeile>
<Zeile label="Gültig bis">{unit.valid_to ? fmtDate(unit.valid_to) : "unbefristet"}</Zeile>
</div>
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Besetzung</h3>
<Zeile label="Leitung">
{leitung ? (
<Link href={`/employees/${leitung.id}`} className="text-brand-700 hover:underline">
{fmtName(leitung.first_name, leitung.last_name)}
</Link>
) : (
<span className="font-semibold text-warning-text">vakant</span>
)}
</Zeile>
<Zeile label="Mitarbeitende">{belegschaft.length}</Zeile>
<Zeile label="Unbesetzte Planstellen">{offene.length}</Zeile>
</div>
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Struktur</h3>
<Zeile label="Untergeordnete Einheiten">{kinder.length}</Zeile>
<Zeile label="Personen im gesamten Teilbaum">{imTeilbaum}</Zeile>
</div>
{kinder.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">Direkt darunter</h3>
<ul className="flex flex-col gap-1 text-sm text-ink-body">
{kinder.map((k) => (
<li key={k.id}>
<span className="tabular-nums text-ink-muted">{k.org_number}</span> · {k.name}{" "}
<span className="text-xs text-ink-muted">({k.unit_type})</span>
</li>
))}
</ul>
</div>
)}
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">
Planstellen{stellen ? ` (${stellen.length})` : ""}
</h3>
{stellen === null ? (
<p className="text-sm text-ink-muted">Lädt…</p>
) : stellen.length === 0 ? (
<p className="text-sm text-ink-muted">In dieser Einheit besteht keine Planstelle.</p>
) : (
<ul className="flex flex-col divide-y divide-border-subtle">
{stellen.map((s) => (
<li key={s.id}>
{/* Auch die besetzten sind anklickbar. Was sich daran ändern
lässt, entscheidet die Datenbankfunktion: Einheit und
Gültigkeitsende bleiben bei einer besetzten Stelle
gesperrt, Tätigkeit und Kontierung nicht. */}
<button
type="button"
onClick={() => setBearbeitet(s)}
className="flex w-full flex-col items-start rounded py-1.5 text-left hover:bg-surface
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<span className="text-sm text-ink-body">
<span className="tabular-nums text-ink-muted">{s.position_number}</span> · {s.title}
{s.is_chief && <span className="text-xs text-ink-muted"> (Leitung)</span>}
</span>
<span className="text-xs text-ink-muted">
{s.besetztVon ?? <span className="font-semibold text-warning-text">unbesetzt</span>}
{s.kostenstelle ? ` · Kostenstelle ${s.kostenstelle.code}` : " · ohne Kostenstelle"}
</span>
</button>
</li>
))}
</ul>
)}
<Link href="/positions" className="mt-1.5 inline-block text-xs font-semibold text-brand-700 hover:underline">
Alle unbesetzten Planstellen
</Link>
</div>
<div className="flex flex-wrap gap-2 border-t border-border pt-4">
<Button variant="secondary" size="sm" onClick={() => onAddUnit(unit.id)}>
<Plus className="h-4 w-4" />
Einheit darunter anlegen
</Button>
{entfernbar && (
<Button variant="secondary" size="sm" onClick={() => onDeleteUnit(unit.id)}>
<Trash2 className="h-4 w-4" />
Einheit entfernen
</Button>
)}
</div>
</div>
{/* Derselbe Dialog wie auf der Seite Positionen. Ein zweiter, der
dasselbe tut, liefe beim ersten Zusatz auseinander. */}
{bearbeitet && (
<EditPositionModal
key={bearbeitet.id}
position={bearbeitet}
units={unitOptions}
kostenstellen={kostenstellen}
heute={heute}
onClose={() => {
setBearbeitet(null);
// Die Liste stammt aus dem Zustand dieser Leiste; ohne erneutes
// Laden stünde nach dem Umkontieren weiter die alte Kostenstelle
// da, und der Dialog daneben zeigte die neue.
void laden();
}}
/>
)}
</SlideOver>
);
}

View File

@@ -1,10 +1,15 @@
"use client";
import { ChevronDown, ChevronRight } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { deleteOrgUnit } from "@/actions/org";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { useToast } from "@/components/ui/Toast";
import { CreateOrgUnitModal } from "./CreateOrgUnitModal";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import { OrgUnitPanel } from "./OrgUnitPanel";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
import { fmtName } from "@/lib/format";
@@ -27,6 +32,8 @@ type PositionTreeProps = {
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
const [mode, setMode] = useState<ViewMode>("list");
const { showToast } = useToast();
const router = useRouter();
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
@@ -41,8 +48,70 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
}, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
// Die Einheit, unter der gerade eine neue entstehen soll. Als Kennung und
// nicht als Objekt: nach dem Anlegen lädt die Seite neu, und ein
// festgehaltenes Objekt wäre dann der Stand von vorhin.
const [anlegenUnter, setAnlegenUnter] = useState<string | null>(null);
const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []);
const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null;
// Die Einheit, deren Angaben offen sind. Der Klick auf die Karte war bisher
// ohne Wirkung — bei achtzig Einheiten die naheliegendste Geste ohne Antwort.
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
const onSelectUnit = useCallback((unitId: string) => setGewaehlt(unitId), []);
const einheit = gewaehlt ? (units.find((u) => u.id === gewaehlt) ?? null) : null;
// Ohne Rückfrage, wie beim Entfernen einer Planstelle: entfernbar ist nur,
// was nie eine Planstelle getragen hat und nichts unter sich hat. Alles
// andere weist die Datenbank mit dem Grund ab, und der steht dann im Toast.
const onDeleteUnit = useCallback(
async (unitId: string) => {
const betroffen = units.find((u) => u.id === unitId);
const result = await deleteOrgUnit(unitId);
if (result.success) {
showToast(`${betroffen ? `${betroffen.org_number} · ${betroffen.name}` : "Einheit"} entfernt.`);
// Die Angaben zu einer Einheit, die es nicht mehr gibt, stünden sonst
// offen da.
setGewaehlt(null);
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Entfernen.", "error");
}
},
[units, showToast, router]
);
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
// Der Baum in der Form, die der Änderungsdialog einer Planstelle erwartet.
// Aus denselben Daten gerechnet statt zusätzlich geladen: `hasChief` heisst
// dort „diese Einheit hat schon eine Leitungsplanstelle", und das ist genau
// die Leitung im Baum — besetzt oder als Vakanz.
const unitOptions = useMemo(() => {
const tiefe = new Map<string, number>();
const berechne = (u: OrgUnitNode, gesehen: Set<string>): number => {
const bekannt = tiefe.get(u.id);
if (bekannt !== undefined) return bekannt;
if (!u.parent_id || gesehen.has(u.id)) return 0;
gesehen.add(u.id);
const eltern = units.find((e) => e.id === u.parent_id);
const d = eltern ? berechne(eltern, gesehen) + 1 : 0;
tiefe.set(u.id, d);
return d;
};
const mitLeitung = new Set([
...employees.filter((e) => e.is_chief).map((e) => e.org_unit_id),
...vacancies.filter((v) => v.is_chief).map((v) => v.org_unit_id),
]);
return units.map((u) => ({
id: u.id,
name: u.name,
unit_type: u.unit_type,
depth: berechne(u, new Set()),
hasChief: mitLeitung.has(u.id),
}));
}, [units, employees, vacancies]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
@@ -56,14 +125,45 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
<LazyGraphOrgChart
tree={tree}
isExpanded={isExpanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
) : (
<div className="rounded border border-border bg-white p-4">
{tree.map((node) => (
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
<ListNode
key={node.id}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
))}
</div>
)}
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
{einheit && (
<OrgUnitPanel
unit={einheit}
units={units}
unitOptions={unitOptions}
employees={employees}
vacancies={vacancies}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onClose={() => setGewaehlt(null)}
/>
)}
</div>
);
}
@@ -73,11 +173,17 @@ function ListNode({
depth,
expanded,
onToggle,
onAddUnit,
onDeleteUnit,
onSelectUnit,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void;
onSelectUnit: (unitId: string) => void;
}) {
const expandable = node.children.length > 0;
const open = expanded.has(node.id);
@@ -85,7 +191,7 @@ function ListNode({
return (
<div>
<div
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
className="group/zeile flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
style={{ paddingLeft: depth * 24 + 8 }}
>
{expandable ? (
@@ -108,14 +214,65 @@ function ListNode({
>
{node.label}
</Link>
) : node.unitId ? (
<button
type="button"
onClick={() => onSelectUnit(node.unitId!)}
aria-label={`Angaben zu ${node.label}`}
className="rounded text-left text-sm font-semibold text-ink hover:text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
{node.label}
</button>
) : (
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
)}
{node.sublabel && (
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
)}
{/* Derselbe Knopf wie auf der Karte in der Grafik — beide Ansichten
kommen aus einem Baum, und eine Handlung, die es nur in einer von
beiden gibt, findet man in der anderen nie. */}
{node.unitId && !expandable && (
<button
type="button"
onClick={() => onDeleteUnit(node.unitId!)}
aria-label={`Einheit ${node.label} entfernen`}
title="Einheit entfernen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-danger-solid
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{node.unitId && (
<button
type="button"
onClick={() => onAddUnit(node.unitId!)}
aria-label={`Einheit unter ${node.label} anlegen`}
title="Untergeordnete Einheit anlegen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-brand-700
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
{open &&
node.children.map((child) => (
<ListNode
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
))}
</div>
);
}
@@ -203,6 +360,7 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va
return {
id: key,
kind: "role",
unitId: unit.id,
label: `${unit.org_number} · ${unit.name}`,
// Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt, sondern
// ist das Ganze. „Leitung vakant" las sich dort wie eine offene Stelle,

View File

@@ -2,7 +2,7 @@
import { ChevronRight, Printer } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { fmtDate } from "@/lib/format";
import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print";
@@ -40,6 +40,16 @@ const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [
{ wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" },
];
/**
* Ob die Personalnummer mitgedruckt wird.
*
* Als Kontext und nicht als Eigenschaft durch den Baum: die Angabe betrifft
* eine einzige Zeile ganz unten (PersonZeile), müsste aber sonst durch jede
* Ebene der Seitenerzeugung durchgereicht werden — vier Signaturen für ein
* Häkchen. Gewünscht von Max am 30.09., ausdrücklich mit Wahlmöglichkeit.
*/
const NummerImDruck = createContext(false);
type Format = "a4" | "a3";
// Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein
// wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest
@@ -160,6 +170,7 @@ function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asO
export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) {
const [format, setFormat] = useState<Format>("a4");
const [tiefe, setTiefe] = useState<Tiefe>("team");
const [mitNummer, setMitNummer] = useState(false);
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => new Set(model.divisions.map((d) => d.id)));
const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl");
@@ -203,6 +214,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
}
return (
<NummerImDruck.Provider value={mitNummer}>
<div className="flex flex-col gap-6">
<style>{`
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
@@ -290,6 +302,15 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
</button>
))}
</div>
{/* Auf jeder Tiefe angeboten, nicht nur bei „mit Personen": die
Leitung einer Einheit steht auch auf den Blättern, die sonst
keine Namen führen. Ein Häkchen, das dort fehlte, sähe aus, als
gäbe es dort keine Namen. */}
<label className="mt-3 flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={mitNummer} onChange={(e) => setMitNummer(e.target.checked)} />
Personalnummer hinter den Namen drucken
</label>
</div>
<div className="mt-5">
@@ -412,7 +433,14 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
unterzeile={seite.unterzeile}
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
/>
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
{/* Die Kennung trägt `mitNummer` mit: die Zeilen werden
breiter, also muss der Massstab neu gemessen werden. Ohne
das bliebe der Wert des vorigen Laufs stehen, und das Blatt
liefe über den Rand. */}
<Skaliert
kennung={`${seite.key}|${format}|${tiefe}|${mitNummer}`}
onFaktor={(f) => meldeMassstab(seite.key, f)}
>
<Chart seite={seite} />
</Skaliert>
</Blatt>
@@ -421,6 +449,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
</>
)}
</div>
</NummerImDruck.Provider>
);
}
@@ -635,9 +664,15 @@ function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }
}
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
const mitNummer = useContext(NummerImDruck);
return (
<p className="text-[10px] leading-tight">
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
{/* Die Personalnummer hinter den Namen und nicht in eine eigene Zeile:
auf einem Blatt mit zweihundert Kästen kostet jede zusätzliche Zeile
Massstab, und der entscheidet darüber, ob das Blatt noch lesbar ist.
Tabellenziffern, damit die Nummern untereinander stehen. */}
{mitNummer && <span className="text-ink-muted tabular-nums"> · {p.personnelNumber}</span>}
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
<br />
<span className="text-ink-muted">{p.jobTitle}</span>

View File

@@ -38,6 +38,13 @@ export type OrgUnitNode = {
name: string;
parent_id: string | null;
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
/**
* Nur die Struktursicht liest sie, für die Angaben zur Einheit. Der Druck
* kommt ohne sie aus und lädt sie deshalb auch nicht — optional statt
* überall mitgeschleppt.
*/
valid_from?: string;
valid_to?: string | null;
};
// Generic tree shape both EmployeeTree and PositionTree map their own data
@@ -66,5 +73,13 @@ export type ChartNode = {
coveringFor?: string;
/** Reports below this node in total, not just direct ones. */
totalReports?: number;
/**
* Die Organisationseinheit hinter dem Knoten — gesetzt nur in der
* Struktursicht und nur an Einheiten, nicht an Personen, Tätigkeitsgruppen
* oder Vakanzen. Daran hängt der Knopf „Einheit darunter anlegen“: aus der
* Knoten-ID (`unit-<uuid>`) liesse sich dasselbe herauslösen, aber dann
* wäre die ID plötzlich ein Datenformat und nicht mehr nur ein Schlüssel.
*/
unitId?: string;
children: ChartNode[];
};

View File

@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { PlanstelleZumAendern } from "@/lib/positions";
import type { UnitOption } from "./CreatePositionModal";
// Ändern statt löschen und neu anlegen.
@@ -33,7 +33,7 @@ export function EditPositionModal({
heute,
onClose,
}: {
position: OpenPositionResolved;
position: PlanstelleZumAendern;
units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
@@ -63,20 +63,42 @@ export function EditPositionModal({
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
// Ob sich an den Stammangaben überhaupt etwas geändert hat.
//
// update_position weist einen Aufruf ohne Änderung mit „Es wurde nichts
// geändert." ab — und weil die Umkontierung erst bei dessen Erfolg lief,
// liess sich die Kostenstelle **allein** nicht setzen: die Meldung kam, und
// die neue Kostenstelle blieb liegen. Wer es trotzdem wollte, musste
// nebenbei ein anderes Feld verstellen. Gemeldet im Test vom 29.09. (E.07).
const stammGeaendert =
jobTitle.trim() !== position.title ||
orgUnitId !== position.org_unit_id ||
(isChief && !chiefTaken) !== position.is_chief ||
validFrom !== position.valid_from ||
(validTo || null) !== (position.valid_to ?? null);
async function handleSubmit() {
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
return;
}
if (!stammGeaendert && !kostenstelleGeaendert) {
showToast("Es wurde nichts geändert.", "error");
return;
}
setPending(true);
const result = await updatePosition({
position_id: position.id,
org_unit_id: orgUnitId,
job_title: jobTitle.trim(),
is_chief: isChief && !chiefTaken,
valid_from: validFrom,
valid_to: validTo || null,
});
// Nur aufrufen, wenn es etwas zu ändern gibt. Sonst ist der Fehlschlag
// dieses Schritts die einzige Antwort auf eine reine Umkontierung.
const result = stammGeaendert
? await updatePosition({
position_id: position.id,
org_unit_id: orgUnitId,
job_title: jobTitle.trim(),
is_chief: isChief && !chiefTaken,
valid_from: validFrom,
valid_to: validTo || null,
})
: { success: true as const };
if (result.success && kostenstelleGeaendert) {
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
@@ -96,7 +118,13 @@ export function EditPositionModal({
}
setPending(false);
if (result.success) {
showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert.");
showToast(
stammGeaendert && kostenstelleGeaendert
? "Planstelle geändert und umkontiert."
: kostenstelleGeaendert
? "Planstelle umkontiert."
: "Planstelle geändert."
);
router.refresh();
onClose();
} else {

View File

@@ -6,13 +6,14 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { deleteReport, saveReport } from "@/actions/reports";
import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
import { CONTROL_CLASS, DateField, SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import {
anzahlKriterien,
AUSWAHL_KRITERIEN,
beschreibeKriterien,
JANEIN_KRITERIEN,
kriterienParameter,
ZEITRAUM_KRITERIEN,
@@ -28,6 +29,7 @@ import {
EVENT_TYPE_LABELS,
GROUP_LABELS,
MEASURE_LABELS,
messgroesseLabel,
parseStatuses,
REPORT_PRESETS,
sortKeysForDimension,
@@ -48,6 +50,21 @@ const EVENT_TYPES = EREIGNIS_AUSWAHL;
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
const VORSCHAU_NAMEN = 12;
/**
* Die Exporte, die in ein anderes System geladen werden.
*
* Sie stehen zusammen und getrennt vom Datenexport darüber, weil sie etwas
* anderes sind: der Datenexport gibt Zahlen heraus, die jemand ansieht, diese
* hier legen im Zielsystem Datensätze an. Nebeneinander in einer Reihe
* gleichartiger Karten war das nicht zu sehen — und eine versehentlich
* eingeschränkte Auswahl wirkt sich hier ganz anders aus.
*/
const ZIELSYSTEME: readonly { name: string; pfad: string }[] = [
{ name: "Honestly — Mitarbeiterbefragung", pfad: "/api/export/honestly" },
{ name: "Cornerstone — Lernsystem", pfad: "/api/export/cornerstone" },
{ name: "LOLYO — Mitarbeiter-App", pfad: "/api/export/lolyo" },
];
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
type OrgOption = { id: string; name: string };
@@ -171,17 +188,15 @@ function KriterienBlock({
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
<DateField
label="Von"
dense
type="date"
value={criteria.zeitraum[k.key]?.von ?? ""}
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
/>
<TextField
<DateField
label="Bis"
dense
type="date"
value={criteria.zeitraum[k.key]?.bis ?? ""}
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
/>
@@ -212,7 +227,8 @@ function KriterienBlock({
</fieldset>
<p className="text-xs text-ink-muted">
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
Alle Kriterien gelten für den Bericht <em>und</em> für jeden Export dieser Seite — auch für den
Honestly- und den Cornerstone-Report. Was gerade eingestellt ist, steht auf der jeweiligen Karte.
</p>
</div>
)}
@@ -220,6 +236,27 @@ function KriterienBlock({
);
}
/**
* Was gerade in die Datei geht — Stichtag, Status, Einheit, Standort und jedes
* gesetzte Kriterium, dazu die Zeilenzahl.
*
* Auf den Karten stand nur der Status. Alles andere schränkt den Export
* genauso ein, steht aber eingeklappt weiter oben: eine Datei mit 40 statt 785
* Personen sah genauso aus wie eine vollständige. Für einen Bericht ist das
* ärgerlich, für ein Load-File ins Zielsystem ist es ein Datenstand, den dort
* niemand mehr hinterfragt.
*/
function AuswahlHinweis({ texte, anzahl }: { texte: string[]; anzahl: number }) {
return (
<div className="mb-2 rounded bg-surface px-2 py-1.5 text-xs text-ink-body">
<span className="font-semibold">Auswahl:</span> {texte.join(" · ")}
<span className="mt-0.5 block font-semibold">
{anzahl} {anzahl === 1 ? "Person" : "Personen"}
</span>
</div>
);
}
function formatValue(measure: Measure, value: number): string {
if (measure === "headcount") return String(Math.round(value));
if (measure === "fte") return value.toFixed(1);
@@ -355,13 +392,27 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday");
const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : [];
const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", ");
// Einheit und Standort stehen in der Adresszeile als Kennung; auf der Karte
// hilft nur der Name.
const einheitName = mode === "snapshot" ? units.find((u) => u.id === props.filters.division)?.name : undefined;
const standortName = mode === "snapshot" ? locations.find((l) => l.id === props.filters.location)?.name : undefined;
const auswahlTexte =
mode === "snapshot"
? [
`Stichtag ${fmtDate(props.asOf || todayIso())}`,
`Status ${statusExportLabel}`,
...(einheitName ? [`Einheit ${einheitName}`] : []),
...(standortName ? [`Standort ${standortName}`] : []),
...beschreibeKriterien(props.criteria),
]
: [];
const currentYear = new Date().getFullYear();
const defaultEventFrom = `${currentYear}-01-01`;
const defaultEventTo = `${currentYear}-12-31`;
const heading =
mode === "snapshot"
? `${MEASURE_LABELS[props.measure]} nach ${GROUP_LABELS[props.group]}`
? `${messgroesseLabel(props.measure, selectedStatuses)} nach ${GROUP_LABELS[props.group]}`
: `${props.eventType ? EVENT_TYPE_LABELS[props.eventType as HistoryEventType] : "Ereignisse"} nach ${EVENT_GROUP_LABELS[props.eventGroup]}`;
const totalDisplay = mode === "snapshot" ? formatValue(props.measure, total) : String(Math.round(total));
@@ -418,11 +469,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
/>
<div>
<div className="flex items-end gap-2">
<TextField
<DateField
label="Stichtag"
dense
className="flex-1"
type="date"
value={props.asOf || todayIso()}
onChange={(v) => updateParams({ asOf: v })}
/>
@@ -503,10 +553,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<legend className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Zeitraum</legend>
<div className="grid grid-cols-2 gap-2">
<div>
<TextField
<DateField
label="Von"
dense
type="date"
value={props.eventFilters.from === EVENT_DATE_OPEN ? "" : props.eventFilters.from || defaultEventFrom}
disabled={props.eventFilters.from === EVENT_DATE_OPEN}
onChange={(v) => updateParams({ from: v })}
@@ -521,10 +570,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</Button>
</div>
<div>
<TextField
<DateField
label="Bis"
dense
type="date"
value={props.eventFilters.to === EVENT_DATE_OPEN ? "" : props.eventFilters.to || defaultEventTo}
disabled={props.eventFilters.to === EVENT_DATE_OPEN}
onChange={(v) => updateParams({ to: v })}
@@ -619,8 +667,8 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vollständiger Datenexport</h3>
{mode === "snapshot" ? (
<>
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten (nicht nur die Kennzahl){props.asOf ? ` zum Stichtag ${fmtDate(props.asOf)}` : ""}.</p>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten, nicht nur die Kennzahl.</p>
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
</>
) : (
<p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p>
@@ -637,44 +685,31 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div>
</div>
{/* Nur im Bestand: eine Befragung richtet sich an Personen, nicht an
Ereignisse. Dieselben Filter wie der vollständige Export darüber
— was dort steht, steht auch hier. */}
{/* Nur im Bestand: ein Zielsystem führt Personen, keine Ereignisse.
Dieselben Filter wie der vollständige Export darüber. */}
{mode === "snapshot" && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
<div className="flex gap-2">
<a href={exportHref("/api/export/honestly", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref("/api/export/honestly", "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
</div>
</div>
)}
{mode === "snapshot" && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Cornerstone Report</h3>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Exporte für Zielsysteme</h3>
<p className="mb-1 text-xs text-ink-muted">
Load-File für Cornerstone: 57 Spalten in der Reihenfolge der Vorlage, kommagetrennt, Datum
TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und
Position ID — dafür fehlen die Kennungen des Zielsystems.
Load-Dateien für Fremdsysteme, jede in deren eigenem Format. Für alle gilt dieselbe Auswahl.
</p>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
<div className="flex gap-2">
<a href={exportHref("/api/export/cornerstone", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref("/api/export/cornerstone", "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
<div className="flex flex-col divide-y divide-border-subtle">
{ZIELSYSTEME.map((z) => (
<div key={z.pfad} className="py-2 first:pt-0 last:pb-0">
<p className="mb-1.5 text-sm font-semibold text-ink">{z.name}</p>
<div className="flex gap-2">
<a href={exportHref(z.pfad, "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref(z.pfad, "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
</div>
</div>
))}
</div>
</div>
)}

View File

@@ -1,6 +1,13 @@
"use client";
import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react";
import {
useId,
useState,
type ReactNode,
type SelectHTMLAttributes,
type InputHTMLAttributes,
type TextareaHTMLAttributes,
} from "react";
// Form primitives.
//
@@ -112,6 +119,74 @@ export function TextField({ label, required, hint, error, dense, className, valu
);
}
/** Ein vollständiges Datum mit vierstelliger Jahreszahl. */
const VOLLSTAENDIGES_DATUM = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ob ein Datumswert eine Aussage ist — oder ein Zwischenstand beim Tippen.
*
* Exportiert, weil der Stichtag im Organigramm dieselbe Frage stellt und dort
* ein blankes `<input>` steht. Zwei Fassungen derselben Regel laufen in diesem
* Projekt erfahrungsgemäss auseinander.
*/
export function istMeldbaresDatum(wert: string): boolean {
// Leer ist eine Aussage — „kein Datum" —, und die soll durchkommen.
if (wert === "") return true;
if (!VOLLSTAENDIGES_DATUM.test(wert)) return false;
// Ein Jahr unter 1000 entsteht beim Tippen: wer „2026" eingibt, erzeugt
// unterwegs 0002, 0020 und 0202. Das sind gültige Daten und damit nicht von
// einer echten Eingabe zu unterscheiden — ausser an ihrer Grösse.
return Number(wert.slice(0, 4)) >= 1000;
}
/**
* Ein Datumsfeld, das Zwischenstände für sich behält.
*
* Der Anlass: in den Berichten hängt jedes Feld an der Adresszeile — eine
* Änderung heisst `router.push`, also neu laden und neu rendern. Ein
* `<input type="date">` meldet beim Tippen der Jahreszahl aber viermal:
* 0002, 0020, 0202, 2026. Die ersten drei lösten je eine Navigation aus, und
* jede setzte das Feld auf den Stand aus der Adresse zurück — mitten im
* Tippen. Mit der Maus über den Kalender ging es, weil der in einem Zug ein
* fertiges Datum setzt.
*
* Deshalb: der Tippstand bleibt hier, gemeldet wird nur, was eine Aussage ist
* — ein vollständiges Datum mit plausibler Jahreszahl, oder das Leeren des
* Feldes. Beim Verlassen wird nachgereicht, was bis dahin liegengeblieben ist.
*/
export function DateField({ value, onChange, onBlur, ...rest }: Omit<TextFieldProps, "type">) {
const [entwurf, setEntwurf] = useState(value);
const [zuletzt, setZuletzt] = useState(value);
// Von aussen gewinnt: „Zurücksetzen" in den Kriterien leert das Feld, und
// nach dem Melden steht hier ohnehin schon dasselbe.
//
// Während des Renderns angeglichen und nicht in einem Effekt: ein Effekt
// liefe erst nach dem Zeichnen, das Feld zeigte also für einen Bildaufbau
// den alten Stand — und die Regel „kein setState im Effekt" verbietet ihn
// aus genau diesem Grund.
if (value !== zuletzt) {
setZuletzt(value);
setEntwurf(value);
}
return (
<TextField
{...rest}
type="date"
value={entwurf}
onChange={(v) => {
setEntwurf(v);
if (istMeldbaresDatum(v) && v !== value) onChange(v);
}}
onBlur={(e) => {
if (entwurf !== value && istMeldbaresDatum(entwurf)) onChange(entwurf);
onBlur?.(e);
}}
/>
);
}
type Option = { value: string; label: string; disabled?: boolean };
type SelectFieldProps = FieldShellProps &

View File

@@ -0,0 +1,866 @@
-- Die Cornerstone-ID als eigenes, freiwilliges Feld.
--
-- Cornerstone (CSOD) führt jede Person unter einer eigenen Kennung. Der
-- Export dorthin (lib/cornerstone.ts) trug in „User ID" und „Username"
-- bisher die Alpenwerk-UUID — richtig, solange es nichts Besseres gab, aber
-- eben nicht die Kennung, unter der Cornerstone die Person kennt. Ab jetzt
-- steht dort diese Spalte.
--
-- Freiwillig: für 434 der 784 Personen liefert die Zuordnungstabelle des
-- Kunden kein „#N/A", also keine Kennung. Ein Pflichtfeld hätte jede
-- Änderung an diesen Zeilen blockiert.
--
-- Eindeutig: eine Kennung gehört genau einer Person. Mehrere ohne Kennung
-- stören den Index nicht, weil null nie gleich null ist.
--
-- Text und keine Zahl: die Kennungen sind achtstellig, zwei sind
-- siebenstellig, und führende Nullen wären in einer Zahlenspalte verloren.
-- Gerechnet wird damit ohnehin nicht.
--
-- Fünf Funktionen müssen mit, weil `create or replace` die ganze Fassung
-- ersetzt und ein ausgelassenes Feld dort still verschwindet — dieselbe
-- Liste und derselbe Grund wie bei der Firmen-E-Mail (20260923120000):
-- hire_employee und rehire_employee teilen sich den Schritt „Person",
-- change_employee_data macht das Feld änderbar, apply_due_pending_changes
-- sorgt dafür, dass eine auf später datierte Änderung nicht verfällt, und
-- app_feld_karte, damit der Eintrag in der Historie richtigstellbar bleibt.
alter table employees add column if not exists cornerstone_id text;
comment on column employees.cornerstone_id is
'Kennung der Person im Lernsystem Cornerstone (CSOD). Freiwillig, eindeutig. Füllt User ID und Username im Cornerstone-Export.';
create unique index if not exists employees_cornerstone_id_key on employees (cornerstone_id);
-- ── hire_employee (Vorlage: 20260923120000_firmenemail.sql) ──────────
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
RETURNS uuid
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
v_schutz boolean := coalesce((payload->>'has_kuendigungsschutz')::boolean, false);
v_behindert boolean := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, false);
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
raise exception 'Es muss eine Personalnummer angegeben werden.';
end if;
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
end if;
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_entry < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
end if;
if v_bis is not null and v_entry >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_entry);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, company_email, cornerstone_id, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, mitarbeiterart, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
has_kuendigungsschutz, kuendigungsschutz_grund, kuendigungsschutz_ab, kuendigungsschutz_bis,
ist_beguenstigt_behindert, behinderung_grad, behinderung_ab, behinderung_bis,
hat_aufenthaltstitel, aufenthaltstitel_bis,
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
)
values (
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'company_email', payload->>'cornerstone_id', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce(nullif(payload->>'mitarbeiterart', ''), 'Standard'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false),
v_schutz,
-- Grund, Beginn und Ende nur, wenn der Schutz überhaupt gesetzt ist:
-- sonst bliebe ein Rest ohne Bezug stehen, und die Bedingungen wiesen
-- den ganzen Vorgang zurück.
case when v_schutz then nullif(payload->>'kuendigungsschutz_grund', '') else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_ab', '')::date else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
v_behindert,
case when v_behindert then nullif(payload->>'behinderung_grad', '')::integer else null end,
case when v_behindert then nullif(payload->>'behinderung_ab', '')::date else null end,
case when v_behindert then nullif(payload->>'behinderung_bis', '')::date else null end,
coalesce((payload->>'hat_aufenthaltstitel')::boolean, false),
case when coalesce((payload->>'hat_aufenthaltstitel')::boolean, false)
then nullif(payload->>'aufenthaltstitel_bis', '')::date else null end,
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
nullif(payload->>'emergency_contact_name', ''),
nullif(payload->>'emergency_contact_phone', ''),
nullif(payload->>'emergency_contact_relation', '')
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$function$;
-- ── rehire_employee (Vorlage: 20260923120000_firmenemail.sql) ──────────
CREATE OR REPLACE FUNCTION public.rehire_employee(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_date date := (payload->>'rehire_date')::date;
v_position_id uuid := (payload->>'position_id')::uuid;
v_name text;
v_alt employees%rowtype;
v_besetzt uuid;
v_schutz boolean;
v_behindert boolean;
begin
perform require_hr_admin();
select * into v_alt from employees where id = v_employee_id;
if v_alt.id is null then
raise exception 'Die Person existiert nicht.';
end if;
v_name := v_alt.first_name || ' ' || v_alt.last_name;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
-- Dieselben Prüfungen wie bei einer Neueinstellung. Sie fehlten hier, und
-- das hiess: eine Wiedereinstellung auf eine besetzte oder noch nicht
-- gültige Planstelle scheiterte erst an einem Index oder gar nicht.
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_date < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_date;
end if;
if v_bis is not null and v_date >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_date;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_date);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
-- Die beiden Kennzeichen zuerst: an ihnen hängen Felder, die ohne sie nicht
-- dastehen dürfen (chk_kuendigungsschutz_grund, chk_behinderung).
v_schutz := coalesce((payload->>'has_kuendigungsschutz')::boolean, v_alt.has_kuendigungsschutz);
v_behindert := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, v_alt.ist_beguenstigt_behindert);
update employees set
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
entry_date = v_date,
exit_date = null,
exit_reason = null,
austrittsart = null,
first_name = coalesce(payload->>'first_name', first_name),
last_name = coalesce(payload->>'last_name', last_name),
gender = coalesce((payload->>'gender')::gender_type, gender),
birth_date = coalesce((payload->>'birth_date')::date, birth_date),
sv_nummer = case when payload ? 'sv_nummer' then nullif(payload->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(payload->>'nationality', nationality),
email = case when payload ? 'email' then nullif(payload->>'email', '') else email end,
company_email = case when payload ? 'company_email' then nullif(payload->>'company_email', '') else company_email end,
cornerstone_id = case when payload ? 'cornerstone_id' then nullif(payload->>'cornerstone_id', '') else cornerstone_id end,
phone = case when payload ? 'phone' then nullif(payload->>'phone', '') else phone end,
address = coalesce(payload->>'address', address),
postal_code = coalesce(payload->>'postal_code', postal_code),
city = coalesce(payload->>'city', city),
address_country = coalesce(payload->>'address_country', address_country),
location_id = coalesce((payload->>'location_id')::uuid, location_id),
title_prefix = case when payload ? 'title_prefix'
then coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}')
else title_prefix end,
title_suffix = case when payload ? 'title_suffix'
then coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}')
else title_suffix end,
emergency_contact_name = case when payload ? 'emergency_contact_name' then nullif(payload->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when payload ? 'emergency_contact_phone' then nullif(payload->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when payload ? 'emergency_contact_relation' then nullif(payload->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel)
then case when payload ? 'aufenthaltstitel_bis' then nullif(payload->>'aufenthaltstitel_bis', '')::date else aufenthaltstitel_bis end
else null
end,
-- Die Tätigkeit kommt von der Planstelle, nicht aus dem payload: sie
-- steht am Platz, den die Person einnimmt, und nicht an ihr.
job_title = (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
employment_type = coalesce((payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((payload->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((payload->>'contract_type')::contract_type, contract_type),
contract_end_date = case when payload ? 'contract_end_date' then nullif(payload->>'contract_end_date', '')::date else contract_end_date end,
paygrade = coalesce((payload->>'paygrade')::paygrade_type, paygrade),
source = coalesce((payload->>'source')::source_type, source),
worker_type = coalesce((payload->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(payload->>'mitarbeiterart', ''), mitarbeiterart),
collective_agreement = coalesce((payload->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when payload ? 'work_days'
then coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), work_days)
else work_days end,
is_betriebsrat = coalesce((payload->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen),
dienstwagen_art = case
when coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen)
then coalesce(nullif(payload->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
is_laterale_fuehrung = coalesce((payload->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((payload->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = v_schutz,
kuendigungsschutz_grund = case when v_schutz then
case when payload ? 'kuendigungsschutz_grund' then nullif(payload->>'kuendigungsschutz_grund', '') else kuendigungsschutz_grund end
else null end,
kuendigungsschutz_ab = case when v_schutz then
case when payload ? 'kuendigungsschutz_ab' then nullif(payload->>'kuendigungsschutz_ab', '')::date else kuendigungsschutz_ab end
else null end,
kuendigungsschutz_bis = case when v_schutz then
case when payload ? 'kuendigungsschutz_bis' then nullif(payload->>'kuendigungsschutz_bis', '')::date else kuendigungsschutz_bis end
else null end,
ist_beguenstigt_behindert = v_behindert,
behinderung_grad = case when v_behindert then
case when payload ? 'behinderung_grad' then nullif(payload->>'behinderung_grad', '')::integer else behinderung_grad end
else null end,
behinderung_ab = case when v_behindert then
case when payload ? 'behinderung_ab' then nullif(payload->>'behinderung_ab', '')::date else behinderung_ab end
else null end,
behinderung_bis = case when v_behindert then
case when payload ? 'behinderung_bis' then nullif(payload->>'behinderung_bis', '')::date else behinderung_bis end
else null end
where id = v_employee_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_employee_id, v_date);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_date, 'Wiedereintritt',
'Wiedereinstellung zum ' || v_date || ' auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id,
'Wiedereintritt am ' || v_date);
end;
$function$;
-- ── app_feld_karte (Vorlage: 20260923120000_firmenemail.sql) ──────────
CREATE OR REPLACE FUNCTION public.app_feld_karte()
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path TO 'public', 'pg_temp'
AS $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Firmen-E-Mail', jsonb_build_array('company_email', 'text', 'person'),
'Cornerstone-ID', jsonb_build_array('cornerstone_id', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Aufenthaltstitel', jsonb_build_array('hat_aufenthaltstitel', 'boolean', 'person'),
'Aufenthaltstitel bis', jsonb_build_array('aufenthaltstitel_bis', 'date', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Mitarbeiterart', jsonb_build_array('mitarbeiterart', 'text', 'role'),
'Besetzungsart', jsonb_build_array('source', 'source_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
'Kündigungsschutz Personenkreis', jsonb_build_array('kuendigungsschutz_grund', 'text', 'role'),
'Kündigungsschutz ab', jsonb_build_array('kuendigungsschutz_ab', 'date', 'role'),
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Begünstigt behindert', jsonb_build_array('ist_beguenstigt_behindert', 'boolean', 'role'),
-- 'integer' und nicht 'numeric': der Typ geht in update_history_entry
-- unmittelbar als Umwandlung ins SQL (`%L::%s`). 'numeric' liefe über
-- eine zweite, stillschweigende Umwandlung auf die Spalte und rundete
-- dabei — der Grad ist eine ganze Zahl aus einem Bescheid.
'Grad der Behinderung', jsonb_build_array('behinderung_grad', 'integer', 'role'),
'Behinderung ab', jsonb_build_array('behinderung_ab', 'date', 'role'),
'Behinderung bis', jsonb_build_array('behinderung_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
-- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu
-- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit
-- und Rückkehr ihre eigenen Vorgänge haben.
'Status', jsonb_build_array('status', 'employment_status', null),
'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null),
'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null),
'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null)
);
$function$;
-- ── change_employee_data (Vorlage: 20260923160000_leere_angaben_sind_null.sql) ──────────
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'company_email' then v_person_changes := app_aenderung(v_person_changes, 'Firmen-E-Mail', v_old.company_email, v_person->>'company_email'); end if;
if v_person ? 'cornerstone_id' then v_person_changes := app_aenderung(v_person_changes, 'Cornerstone-ID', v_old.cornerstone_id, v_person->>'cornerstone_id'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'hat_aufenthaltstitel' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel', v_old.hat_aufenthaltstitel::text, v_person->>'hat_aufenthaltstitel'); end if;
if v_person ? 'aufenthaltstitel_bis' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel bis', v_old.aufenthaltstitel_bis::text, (nullif(v_person->>'aufenthaltstitel_bis','')::date)::text); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'mitarbeiterart' then v_contract_changes := app_aenderung(v_contract_changes, 'Mitarbeiterart', v_old.mitarbeiterart, v_role->>'mitarbeiterart'); end if;
if v_role ? 'source' then v_contract_changes := app_aenderung(v_contract_changes, 'Besetzungsart', v_old.source::text, v_role->>'source'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_grund' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz Personenkreis', v_old.kuendigungsschutz_grund, nullif(v_role->>'kuendigungsschutz_grund', '')); end if;
if v_role ? 'kuendigungsschutz_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz ab', v_old.kuendigungsschutz_ab::text, (nullif(v_role->>'kuendigungsschutz_ab','')::date)::text); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'ist_beguenstigt_behindert' then v_contract_changes := app_aenderung(v_contract_changes, 'Begünstigt behindert', v_old.ist_beguenstigt_behindert::text, v_role->>'ist_beguenstigt_behindert'); end if;
if v_role ? 'behinderung_grad' then v_contract_changes := app_aenderung(v_contract_changes, 'Grad der Behinderung', v_old.behinderung_grad::text, (nullif(v_role->>'behinderung_grad','')::integer)::text); end if;
if v_role ? 'behinderung_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung ab', v_old.behinderung_ab::text, (nullif(v_role->>'behinderung_ab','')::date)::text); end if;
if v_role ? 'behinderung_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung bis', v_old.behinderung_bis::text, (nullif(v_role->>'behinderung_bis','')::date)::text); end if;
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = case when v_person ? 'sv_nummer' then nullif(v_person->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_person->>'nationality', nationality),
address = case when v_person ? 'address' then nullif(v_person->>'address', '') else address end,
postal_code = case when v_person ? 'postal_code' then nullif(v_person->>'postal_code', '') else postal_code end,
city = case when v_person ? 'city' then nullif(v_person->>'city', '') else city end,
address_country = case when v_person ? 'address_country' then nullif(v_person->>'address_country', '') else address_country end,
email = case when v_person ? 'email' then nullif(v_person->>'email', '') else email end,
phone = case when v_person ? 'phone' then nullif(v_person->>'phone', '') else phone end,
company_email = case when v_person ? 'company_email' then nullif(v_person->>'company_email', '') else company_email end,
cornerstone_id = case when v_person ? 'cornerstone_id' then nullif(v_person->>'cornerstone_id', '') else cornerstone_id end,
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
-- Fällt der Titel weg, fällt das Datum mit. Sonst bliebe ein
-- Enddatum ohne Titel stehen, und chk_aufenthaltstitel_bis liesse
-- die ganze Änderung scheitern statt das Offensichtliche zu tun.
aufenthaltstitel_bis = case
when coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_person ? 'aufenthaltstitel_bis'
then nullif(v_person->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
-- Ohne nullif: die Spalte ist NOT NULL, ein leerer Text waere kein
-- geloeschter Wert, sondern ein Wert, den chk_mitarbeiterart abweist.
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
-- Fällt der Schutz weg, fallen Grund und beide Daten mit. Andernfalls
-- bliebe ein Rest ohne Bezug stehen — die Bedingungen verbieten das,
-- und der Vorgang schlüge fehl, statt das Offensichtliche zu tun.
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund'
then nullif(v_role->>'kuendigungsschutz_grund','')
else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab'
then nullif(v_role->>'kuendigungsschutz_ab','')::date
else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
-- ── Die Übernahme ────────────────────────────────────────────────
--
-- Nur in dieser einen Richtung. Wer von extern nach intern wechselt, wird
-- übernommen; der umgekehrte Weg ist kein Vorgang, den es fachlich gibt.
--
-- Das Ereignis entsteht **zusätzlich** zur Vertragsänderung darüber und
-- nicht an ihrer Stelle: die eine hält fest, dass ein Feld sich geändert
-- hat und worauf (und lässt sich darüber zurücknehmen), das andere, dass
-- dieser Wechsel eine Übernahme war — und nur das lässt sich zählen.
--
-- `v_effective_date`, nicht `now()`: eine auf später datierte Übernahme
-- gehört in die Akte auf den Tag, an dem sie gilt. Bei einer sofortigen
-- ist das heute.
if v_role ? 'source' and v_old.source::text = 'Extern' and v_role->>'source' = 'Intern' then
insert into employee_history (employee_id, event_date, event_type, description, pending_id)
values (v_employee_id, v_effective_date, 'Übernahme',
'Übernahme von extern auf intern, wirksam ab ' || v_effective_date, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Übernahme', v_name, v_employee_id,
'Von extern auf intern, wirksam ab ' || v_effective_date);
end if;
end;
$function$;
-- ── apply_due_pending_changes (Vorlage: 20260924180000_datierte_aenderung_vollstaendig.sql) ──────────
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count integer := 0;
v_role jsonb;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
-- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen
-- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt
-- — die Fassung von vor der Umstellung auf Planstellen. Eine auf
-- später datierte Versetzung galt damit am Stichtag als angewendet
-- und bewegte niemanden.
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
-- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig
-- stünde am Stichtag der neue Titel an der Person und sie säsze
-- weiterhin auf der alten Stelle — im Organigramm unverändert.
if nullif(v_rec.payload->>'target_position_id', '') is not null then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
end if;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
v_role := v_rec.payload->'role';
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end,
postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end,
city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end,
address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end,
email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end,
phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end,
company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end,
cornerstone_id = case when v_rec.payload->'person' ? 'cornerstone_id' then nullif(v_rec.payload->'person'->>'cornerstone_id', '') else cornerstone_id end,
-- Der Notfallkontakt und die Titel fehlten hier. Eine auf später
-- datierte Änderung stand damit in der Akte, kam aber nie an der
-- Person an: change_employee_data schreibt sie sofort, dieser Zweig
-- kannte sie nicht. Dieselbe Lücke wie einst bei der Gruppe `role`
-- (20260915120000).
emergency_contact_name = case when v_rec.payload->'person' ? 'emergency_contact_name'
then nullif(v_rec.payload->'person'->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_rec.payload->'person' ? 'emergency_contact_phone'
then nullif(v_rec.payload->'person'->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_rec.payload->'person' ? 'emergency_contact_relation'
then nullif(v_rec.payload->'person'->>'emergency_contact_relation', '') else emergency_contact_relation end,
-- Wie work_days weiter unten: eine leere Liste ist eine Angabe, kein
-- fehlender Wert, deshalb coalesce auf '{}' statt auf den alten Stand.
title_prefix = case when v_rec.payload->'person' ? 'title_prefix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}')
else title_prefix end,
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}')
else title_suffix end,
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_fn text;
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employees' and column_name = 'cornerstone_id'
) then
raise exception 'Die Spalte cornerstone_id fehlt.';
end if;
-- Jede der fünf einzeln: fehlt sie in einer, verschwindet das Feld genau
-- in deren Weg, ohne dass es auffällt.
foreach v_fn in array array[
'public.hire_employee(jsonb)',
'public.rehire_employee(jsonb)',
'public.change_employee_data(jsonb)',
'public.apply_due_pending_changes()'
] loop
if pg_get_functiondef(v_fn::regprocedure) not like '%cornerstone_id%' then
raise exception '% kennt die Cornerstone-ID nicht.', v_fn;
end if;
end loop;
if app_feld_karte() -> 'Cornerstone-ID' is null then
raise exception 'Die Feldkarte kennt die Cornerstone-ID nicht — der Eintrag in der Historie waere nicht korrigierbar.';
end if;
-- Und was zuletzt hier verlorenging, bleibt drin.
if pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure) not like '%emergency_contact_name%' then
raise exception 'Der Nachtlauf hat den Notfallkontakt wieder verloren.';
end if;
if pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure) not like '%target_position_id%' then
raise exception 'Der Nachtlauf hat die Zielplanstelle wieder verloren.';
end if;
end
$$;

View File

@@ -0,0 +1,247 @@
-- Hay-Grade statt Verwendungsgruppe A–F
--
-- ═══ 1. Warum die Liste wechselt ═══════════════════════════════════
--
-- A–F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene
-- Stufen mit erfundenen Beschreibungen („B – Qualifiziert"). Der Kunde
-- bewertet nach Hay und hat die Liste geschickt — 13 Stufen plus einen
-- „Generic Grade" für alles, was keine trägt.
--
-- Die Spalte füllt im Cornerstone-Extrakt die „Grade ID". Solange dort A–F
-- steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
-- auffällt: das Zielsystem kennt diese Kennungen nicht.
--
-- ═══ 2. Warum eine Domain und kein Aufzählungstyp ══════════════════
--
-- `paygrade_type` war ein Enum. Ein Enum lässt sich nicht umschreiben:
-- Werte entfernen geht gar nicht, und `alter type … add value` darf im
-- selben Vorgang, der den neuen Wert schreibt, nicht benutzt werden — die
-- Umstellung bräuchte also zwei Migrationen und liesse dazwischen eine
-- Datenbank mit beiden Listen zurück.
--
-- Der Typ **heisst** deshalb weiter `paygrade_type`, ist aber jetzt eine
-- Domain über text mit CHECK. Das ist die Form, die dieses Projekt für
-- kundenseitige Listen ohnehin schon führt (Abwesenheitsart, Personenkreis,
-- Mitarbeiterart) — und, wichtiger: die rund zehn SQL-Funktionen, die
-- `(payload->>'paygrade')::paygrade_type` schreiben, bleiben unverändert
-- gültig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
-- haben, aus einer veralteten Vorlage zu kopieren; genau daran hat dieses
-- Projekt schon dreimal Verhalten verloren.
--
-- Eine einzige Stelle muss doch angefasst werden: `hire_employee` setzt als
-- Vorgabe das Literal 'B', und das besteht den CHECK nicht mehr. Sie wird
-- unten **nicht** aus einer Datei kopiert, sondern aus der laufenden
-- Definition gelesen und an genau dieser Stelle geändert.
-- ═══ 3. Hängt der Typ noch woanders? ══════════════════════════════
--
-- Die Dateien sagen: nur employees.paygrade. Die Datenbank ist die Quelle,
-- nicht die Dateien — also gefragt statt angenommen. Hinge eine zweite
-- Spalte daran, scheiterte der DROP weiter unten ohnehin, aber mit einer
-- Meldung, die nicht sagt, was los ist.
do $$
declare
v_spalten text;
begin
select string_agg(c.relname || '.' || a.attname, ', ')
into v_spalten
from pg_attribute a
join pg_class c on c.oid = a.attrelid
join pg_namespace n on n.oid = c.relnamespace
where a.atttypid = 'paygrade_type'::regtype
and a.attnum > 0
and not a.attisdropped
and n.nspname = 'public'
and not (c.relname = 'employees' and a.attname = 'paygrade');
if v_spalten is not null then
raise exception 'paygrade_type wird noch benutzt von: %. Umstellung abgebrochen.', v_spalten;
end if;
end
$$;
-- ═══ 4. Offene datierte Beförderungen ═════════════════════════════
--
-- Eine Beförderung mit Datum in der Zukunft liegt als pending-Zeile mit
-- `new_paygrade` im Nutzdatensatz. Steht dort A–F, scheiterte der Nachtlauf
-- Wochen später an der Umwandlung — um drei Uhr früh und ohne jemanden, dem
-- die Meldung zugestellt würde. Lieber jetzt und laut.
do $$
declare
v_anzahl int;
begin
select count(*) into v_anzahl
from pending_org_changes
where status = 'pending'
and payload ? 'new_paygrade'
and payload->>'new_paygrade' not in
('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20');
if v_anzahl > 0 then
raise exception
'Es warten % geplante Beförderungen auf eine Verwendungsgruppe, die es nicht mehr gibt. Erst entscheiden, dann umstellen.',
v_anzahl;
end if;
end
$$;
-- ═══ 5. Der Typwechsel ════════════════════════════════════════════
alter table employees alter column paygrade drop default;
alter table employees alter column paygrade type text using paygrade::text;
drop type paygrade_type;
create domain paygrade_type as text
constraint chk_hay_grade check (
value in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20')
);
-- Der Bestand bekommt „Generic Grade". Aus A–F liesse sich kein Hay-Grade
-- ableiten: andere Einteilung, andere Anzahl. Geraten sähe im Extrakt
-- genauso aus wie erhoben — und wäre nicht mehr davon zu unterscheiden.
update employees
set paygrade = '-'
where paygrade not in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20');
alter table employees alter column paygrade type paygrade_type using paygrade::paygrade_type;
alter table employees alter column paygrade set default '-';
-- ═══ 6. hire_employee: Vorgabewert 'B' → '-' ══════════════════════
--
-- Aus der laufenden Definition gelesen, nicht aus einer Migrationsdatei
-- kopiert: ein Teil der Funktionen dieses Projekts ist nachträglich
-- dynamisch gepatcht worden, die Summe der Dateien ist also nicht das, was
-- in der Datenbank steht. Der Anker muss genau einmal vorkommen — käme er
-- keinmal vor, hätte jemand die Vorgabe schon geändert und wir überschrieben
-- etwas Unbekanntes; käme er zweimal vor, träfe die Ersetzung eine Stelle,
-- die hier niemand angeschaut hat.
do $migration$
declare
v_alt constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, 'B')$anker$;
v_neu constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, '-')$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid)
into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.proname = 'hire_employee'
and p.prokind = 'f';
if v_def is null then
raise exception 'hire_employee ist nicht vorhanden.';
end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Vorgabewert kommt % mal vor, erwartet genau einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 6b. promote_employee: die Beschriftung im Protokoll ══════════
--
-- Die Funktion schreibt den Grund einer Beförderung als Klartext nach
-- audit_log und employee_history: „…, neue Verwendungsgruppe: C". Diese
-- Zeilen liest der Kunde. Bliebe das Wort stehen, hiesse ein Feld in der
-- Maske anders als im Protokoll darüber — dieselbe Änderung unter zwei
-- Namen. Bestehende Einträge bleiben, wie sie sind: Historie wird
-- fortgeschrieben, nicht umgeschrieben.
do $migration$
declare
v_alt constant text := $anker$, neue Verwendungsgruppe: $anker$;
v_neu constant text := $anker$, neuer Hay-Grade: $anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid)
into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.proname = 'promote_employee'
and p.prokind = 'f';
if v_def is null then
raise exception 'promote_employee ist nicht vorhanden.';
end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Die Beschriftung kommt % mal vor, erwartet genau einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 7. Selbstprüfung ═════════════════════════════════════════════
--
-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren
-- Neuerzeugungen schon einmal still verschwunden ist: Rechteprüfung,
-- search_path und die zuletzt hinzugekommenen Felder. `hire_employee` ist
-- gerade neu erzeugt worden — wenn dabei etwas abhanden gekommen wäre, wäre
-- es hier zu sehen und nirgends sonst.
do $$
declare
v_def text;
v_werte int;
begin
select count(*) into v_werte
from pg_constraint
where conname = 'chk_hay_grade'
and contypid = 'paygrade_type'::regtype;
if v_werte <> 1 then
raise exception 'Die Bedingung chk_hay_grade fehlt an paygrade_type.';
end if;
if exists (select 1 from employees where paygrade::text = 'B') then
raise exception 'Es stehen noch Verwendungsgruppen in employees.paygrade.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' and p.prokind = 'f';
if v_def not like '%paygrade_type, ''-''%' then
raise exception 'hire_employee traegt den neuen Vorgabewert nicht.';
end if;
if v_def like '%paygrade_type, ''B''%' then
raise exception 'hire_employee traegt noch den alten Vorgabewert.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'hire_employee hat die Rechtepruefung verloren.';
end if;
if v_def not like '%search_path%' then
raise exception 'hire_employee hat den search_path verloren.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'hire_employee hat die Cornerstone-ID verloren.';
end if;
if v_def not like '%company_email%' then
raise exception 'hire_employee hat die Firmen-E-Mail verloren.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
if v_def not like '%neuer Hay-Grade%' then
raise exception 'promote_employee traegt die neue Beschriftung nicht.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'promote_employee hat die Rechtepruefung verloren.';
end if;
-- Die Zielplanstelle kam erst mit 20260924100000 dazu und ist genau die Art
-- Verhalten, die bei einer Neuerzeugung schon einmal still verschwunden ist.
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle verloren.';
end if;
raise notice 'Hay-Grade steht, hire_employee und promote_employee vollstaendig.';
end
$$;

View File

@@ -0,0 +1,199 @@
-- Der Hay-Grade in „Daten ändern"
--
-- Die Beförderung konnte ihn schon setzen, „Daten ändern" nicht: weder das
-- Formular noch change_employee_data kannten das Feld. Damit war eine
-- Einstufung nur über den Weg „Beförderung" zu ändern — und eine Korrektur
-- („da stand von Anfang an die falsche Stufe") ist keine Beförderung, sondern
-- eine Richtigstellung, die auch keine Planstelle wechseln soll.
--
-- Das Feld gehört zur Gruppe `contract` und ist damit **datiert**: eine
-- Umstufung gilt ab einem Tag, nicht rückwirkend seit immer. Das heisst, sie
-- kann in pending_org_changes landen — und deshalb steht sie unten auch im
-- Nachtlauf. Genau diese zweite Stelle ist in diesem Projekt schon einmal
-- vergessen worden: die Gruppe `role` fehlte dort monatelang, und eine
-- datierte Änderung an Betriebsrat oder Kollektivvertrag wurde als `applied`
-- vermerkt, ohne etwas zu tun.
--
-- Drittens app_feld_karte: ohne Eintrag dort liesse sich der Eintrag in der
-- Historie hinterher nicht richtigstellen.
--
-- Alle drei Funktionen werden **nicht** aus einer Datei kopiert, sondern aus
-- der laufenden Definition gelesen und an genau einem Anker ergänzt. Jeder
-- Anker muss genau einmal vorkommen; sonst bricht die Migration ab, statt an
-- einer Stelle zu schreiben, die hier niemand angesehen hat.
-- ═══ 1. change_employee_data ══════════════════════════════════════
do $migration$
declare
-- Der Eintrag im Änderungsprotokoll …
v_alt_log constant text := $anker$if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;$anker$;
v_neu_log constant text := $anker$if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_contract ? 'paygrade' then v_contract_changes := app_aenderung(v_contract_changes, 'Hay-Grade', v_old.paygrade::text, v_contract->>'paygrade'); end if;$anker$;
-- … und die Spalte selbst. coalesce und nicht nullif: die Stufe ist NOT NULL,
-- sie lässt sich nicht leeren, nur wechseln.
v_alt_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),$anker$;
v_neu_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
paygrade = coalesce((v_contract->>'paygrade')::paygrade_type, paygrade),$anker$;
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
if v_def is null then
raise exception 'change_employee_data ist nicht vorhanden.';
end if;
if (length(v_def) - length(replace(v_def, v_alt_log, ''))) / length(v_alt_log) <> 1 then
raise exception 'Der Anker für das Protokoll kommt nicht genau einmal vor.';
end if;
if (length(v_def) - length(replace(v_def, v_alt_upd, ''))) / length(v_alt_upd) <> 1 then
raise exception 'Der Anker für die Spalte kommt nicht genau einmal vor.';
end if;
if v_def like '%paygrade%' then
raise exception 'change_employee_data kennt den Hay-Grade bereits.';
end if;
execute replace(replace(v_def, v_alt_log, v_neu_log), v_alt_upd, v_neu_upd);
end
$migration$;
-- ═══ 2. apply_due_pending_changes ═════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),$anker$;
v_neu constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
paygrade = coalesce((v_rec.payload->'contract'->>'paygrade')::paygrade_type, paygrade),$anker$;
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
if v_def is null then
raise exception 'apply_due_pending_changes ist nicht vorhanden.';
end if;
if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then
raise exception 'Der Anker im Nachtlauf kommt nicht genau einmal vor.';
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 3. app_feld_karte ════════════════════════════════════════════
do $migration$
declare
-- Der zweite Eintrag ist der Typ, den update_history_entry unmittelbar als
-- Umwandlung ins SQL setzt. paygrade_type ist seit 20260928140000 eine
-- Domain über text; die Prüfung der Stufe passiert damit dort, wo sie
-- hingehört, und nicht an einer zweiten Liste in dieser Funktion.
v_alt constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'),$anker$;
v_neu constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'),
'Hay-Grade', jsonb_build_array('paygrade', 'paygrade_type', 'contract'),$anker$;
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'app_feld_karte' and p.prokind = 'f';
if v_def is null then
raise exception 'app_feld_karte ist nicht vorhanden.';
end if;
if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then
raise exception 'Der Anker in der Feldkarte kommt nicht genau einmal vor.';
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 4. Selbstprüfung ═════════════════════════════════════════════
--
-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren
-- Neuerzeugungen in diesem Projekt schon einmal still verschwunden ist.
do $$
declare
v_def text;
v_anz int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
if v_def not like '%''Hay-Grade''%' then
raise exception 'change_employee_data protokolliert den Hay-Grade nicht.';
end if;
if v_def not like '%v_contract->>''paygrade''%' then
raise exception 'change_employee_data schreibt den Hay-Grade nicht.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'change_employee_data hat die Rechtepruefung verloren.';
end if;
if v_def not like '%search_path%' then
raise exception 'change_employee_data hat den search_path verloren.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'change_employee_data hat die Cornerstone-ID verloren.';
end if;
-- Die Form, die eine leere Angabe als null schreibt statt als '' (siehe
-- 20260923160000). Ginge sie verloren, scheiterte die zweite Person ohne
-- E-Mail am eindeutigen Index.
if v_def not like '%nullif(v_person->>''email'', '''')%' then
raise exception 'change_employee_data hat die Leerstring-Behandlung verloren.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
if v_def not like '%''contract''->>''paygrade''%' then
raise exception 'Der Nachtlauf wendet den Hay-Grade nicht an.';
end if;
if v_def not like '%v_rec.payload->''role''%' then
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
end if;
-- Zwei Zweige hängen eine Besetzung um: die Versetzung und die Beförderung
-- auf eine andere Planstelle. Gezählt und nicht bloss auf Vorhandensein
-- geprüft — einer der beiden ist hier schon einmal verschwunden.
v_anz := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
/ length('insert into position_assignments');
if v_anz <> 2 then
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anz;
end if;
-- Und was zuletzt hier verlorenging, bleibt drin.
if v_def not like '%emergency_contact_name%' then
raise exception 'Der Nachtlauf hat den Notfallkontakt wieder verloren.';
end if;
if v_def not like '%target_position_id%' then
raise exception 'Der Nachtlauf hat die Zielplanstelle wieder verloren.';
end if;
-- Der Lauf hat keine angemeldete Person: er arbeitet als SECURITY DEFINER
-- und ruft deshalb **nicht** require_hr_admin(). Ginge das Kennzeichen bei
-- einer Neuerzeugung verloren, liefe er nachts in die Zeilenschutz-Regeln
-- und täte nichts mehr.
if not exists (
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prosecdef
) then
raise exception 'Der Nachtlauf ist nicht mehr SECURITY DEFINER.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'app_feld_karte' and p.prokind = 'f';
if v_def not like '%''Hay-Grade''%' then
raise exception 'Die Feldkarte kennt den Hay-Grade nicht.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'Die Feldkarte hat die Cornerstone-ID verloren.';
end if;
raise notice 'Hay-Grade steht in Daten aendern, im Nachtlauf und in der Feldkarte.';
end
$$;

View File

@@ -0,0 +1,156 @@
-- Eine Organisationseinheit aus der Anwendung heraus anlegen
--
-- Bisher gab es dafür nur den Import — und für alles andere an einer Einheit
-- (umbenennen, verschieben, schliessen) gibt es weiterhin nur die Datenbank.
-- Das hier ist bewusst nur der eine Fall: **eine neue Einheit unter einer
-- bestehenden**.
--
-- Warum gerade der: er ist der einzige, der nichts umschreibt. Die neue
-- Einheit ist immer ein Blatt, also
--
-- * kann kein Kreis entstehen — und das ist keine Kleinigkeit: die
-- rekursive Abfrage `ancestry` in om_reporting_lines hat weder
-- Tiefenbegrenzung noch Kreiserkennung, ein Kreis in parent_id würde dort
-- nicht zu einem falschen Ergebnis führen, sondern zu keinem;
-- * wird keine Vergangenheit verändert. org_units.parent_id trägt kein
-- Datum: wer eine bestehende Einheit umhängt, ändert damit auch jede
-- Auswertung auf einen vergangenen Stichtag, und der frühere Stand ist
-- danach nirgends mehr ablesbar. Beim Anlegen stellt sich die Frage
-- nicht, weil vorher nichts da war.
--
-- Das Verschieben bestehender Einheiten braucht deshalb eine Entscheidung
-- (Vergangenheit überschreiben oder die Aufhängung datieren) und steht
-- absichtlich noch nicht hier.
create or replace function create_org_unit(payload jsonb)
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_parent_id uuid := (payload->>'parent_id')::uuid;
v_org_number text := nullif(trim(payload->>'org_number'), '');
v_name text := nullif(trim(payload->>'name'), '');
v_unit_type text := nullif(trim(payload->>'unit_type'), '');
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
v_leitung text := nullif(trim(payload->>'leitung_taetigkeit'), '');
v_parent record;
v_id uuid;
begin
perform require_hr_admin();
if v_parent_id is null then
-- Eine Einheit ohne Übergeordnete wäre eine zweite Wurzel. Es gibt genau
-- eine, und sie entsteht nicht hier.
raise exception 'Es muss eine übergeordnete Einheit angegeben werden.';
end if;
select id, org_number, name, valid_from, valid_to
into v_parent
from org_units
where id = v_parent_id;
if v_parent.id is null then
raise exception 'Die übergeordnete Einheit existiert nicht.';
end if;
if v_org_number is null then
raise exception 'Es muss eine Orgnummer angegeben werden.';
end if;
if v_name is null then
raise exception 'Es muss eine Bezeichnung angegeben werden.';
end if;
if v_unit_type is null then
raise exception 'Es muss eine Art angegeben werden.';
end if;
-- Die Gesellschaft ist das Ganze und steht über allem. Als Kind einer
-- anderen Einheit wäre sie ein Etikett, das seiner Stelle im Baum
-- widerspricht — und genau daran ist in diesem Projekt schon dreimal Code
-- zerbrochen, der aus dem Etikett auf die Ebene geschlossen hat.
if v_unit_type = 'Gesellschaft' then
raise exception 'Die Gesellschaft ist die Wurzel und kann nicht unter einer anderen Einheit stehen.';
end if;
-- Der eindeutige Index würde das ebenfalls abfangen, aber mit einer
-- Meldung, die in der Oberfläche nichts erklärt.
if exists (select 1 from org_units where org_number = v_org_number) then
raise exception 'Die Orgnummer % ist bereits vergeben.', v_org_number;
end if;
-- Eine Einheit, die früher beginnt als die über ihr, hängt an einem Tag im
-- Baum, an dem es diesen Baum noch nicht gab. Auf einen solchen Stichtag
-- ausgewertet stünde sie ohne Vorfahren da.
if v_valid_from < v_parent.valid_from then
raise exception 'Die übergeordnete Einheit gilt erst ab %. Ein früherer Beginn ist nicht möglich.', v_parent.valid_from;
end if;
if v_parent.valid_to is not null and v_valid_from >= v_parent.valid_to then
raise exception 'Die übergeordnete Einheit gilt nur bis %.', v_parent.valid_to;
end if;
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
values (v_org_number, v_name, v_parent_id, v_unit_type::org_unit_type, v_valid_from)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (app_current_user_id(), current_actor_name(), 'Organisationseinheit angelegt',
v_org_number || ' · ' || v_name,
'Unter ' || v_parent.org_number || ' · ' || v_parent.name ||
', Art ' || v_unit_type || ', gültig ab ' || v_valid_from ||
case when v_leitung is not null then ', mit Leitungsplanstelle „' || v_leitung || '“' else '' end);
-- Die Leitungsplanstelle entsteht über create_position und nicht durch eine
-- zweite Fassung derselben Logik. Dort hängen der Jobkatalog (gleiche
-- Tätigkeit = ein Eintrag), die Nummernvergabe und die Prüfung „je Einheit
-- genau eine Leitung“ — nachgebaut liefe das hier beim ersten Zusatz
-- auseinander. Sie schreibt ihren eigenen Protokolleintrag.
if v_leitung is not null then
perform create_position(jsonb_build_object(
'org_unit_id', v_id,
'job_title', v_leitung,
'is_chief', true,
'valid_from', v_valid_from
));
end if;
return v_id;
end;
$$;
comment on function create_org_unit(jsonb) is
'Legt eine Organisationseinheit unter einer bestehenden an, wahlweise gleich mit Leitungsplanstelle. Nur anlegen: umbenennen, verschieben und schliessen gibt es bewusst nicht, weil parent_id kein Datum trägt.';
grant execute on function create_org_unit(jsonb) to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'create_org_unit' and p.prokind = 'f';
if v_def is null then
raise exception 'create_org_unit wurde nicht angelegt';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'create_org_unit prüft die Rechte nicht';
end if;
-- Ohne search_path liesse sich der Funktion über einen eigenen Schemapfad
-- eine andere Tabelle unterschieben. Hauskonvention für jede Funktion.
if v_def not like '%search_path%' then
raise exception 'create_org_unit hat keinen festen search_path';
end if;
if v_def not like '%audit_log%' then
raise exception 'create_org_unit schreibt nichts ins Protokoll';
end if;
-- Nicht nachgebaut, sondern aufgerufen — siehe den Kommentar dort.
if v_def not like '%create_position(%' then
raise exception 'create_org_unit legt die Leitungsplanstelle nicht über create_position an';
end if;
-- Ohne Übergeordnete entstünde eine zweite Wurzel.
if v_def not like '%parent_id%' then
raise exception 'create_org_unit kennt die übergeordnete Einheit nicht';
end if;
end
$$;

View File

@@ -0,0 +1,127 @@
-- Eine Organisationseinheit wieder entfernen
--
-- Das Gegenstück zu create_org_unit und mit derselben Einschränkung: es geht
-- nur dort, wo nichts umgeschrieben wird.
--
-- ═══ Warum gelöscht und nicht geschlossen ═════════════════════════
--
-- delete_position macht es seit dem OM-Umbau vor: besetzt → Abweisung, einmal
-- besetzt gewesen → `valid_to` statt Löschen (sonst verschwände mit der Stelle
-- die Besetzungshistorie), nie besetzt gewesen → weg. Der Unterschied ist
-- keine Vorsicht, sondern eine Aussage: was nie getragen hat, hat es auch nie
-- gegeben.
--
-- Für Einheiten lässt sich davon heute nur die letzte Stufe umsetzen. Ein
-- `valid_to` an einer Einheit wäre zwar eingetragen, aber **nirgends
-- gelesen**: weder das Organigramm noch orgMapsAbfragen, die Berichte, der
-- Druck oder die Auswahl beim Anlegen einer Planstelle schränken org_units auf
-- den Stichtag ein. Die geschlossene Einheit stünde überall weiter da, nur mit
-- einem Datum, das niemand sieht — das wäre schlechter als die klare
-- Abweisung unten, weil es wie Erledigt aussieht.
--
-- Deshalb hier nur: eine Einheit, unter der nie etwas hing, verschwindet
-- ganz. Alles andere wird abgewiesen, mit dem Grund. Das Schliessen zum
-- Stichtag kommt, wenn org_units überall gegen den Stichtag gelesen wird —
-- dieselbe Arbeit, die auch das Verschieben braucht.
create or replace function delete_org_unit(payload jsonb)
returns void language plpgsql
set search_path = public, pg_temp
as $$
declare
v_id uuid := (payload->>'org_unit_id')::uuid;
v_unit record;
v_einheiten int;
v_stellen int;
begin
perform require_hr_admin();
select id, org_number, name, parent_id into v_unit from org_units where id = v_id;
if v_unit.id is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_unit.parent_id is null then
raise exception 'Die Gesellschaft ist die Wurzel und kann nicht entfernt werden.';
end if;
select count(*) into v_einheiten from org_units where parent_id = v_id;
if v_einheiten > 0 then
raise exception 'Unter dieser Einheit hängen noch % Einheiten. Sie müssen zuerst woanders hin.', v_einheiten;
end if;
-- Geschlossene Planstellen zählen mit. Sie tragen weiterhin diese Einheit,
-- und mit ihr verschwände die Angabe, wo jemand einmal gesessen hat.
select count(*) into v_stellen from om_positions where org_unit_id = v_id;
if v_stellen > 0 then
raise exception
'In dieser Einheit bestehen noch % Planstellen (geschlossene zählen mit). Sie müssen zuerst entfernt oder verschoben werden.',
v_stellen;
end if;
-- Die Kostenstellen tragen die Einheit nur als Hinweis (siehe Migration
-- 20260816100000), nicht als Zuordnung. Sie stünden nach dem Löschen auf
-- einer Einheit, die es nicht mehr gibt — der Fremdschlüssel würde das
-- abweisen, also vorher und mit Grund.
if exists (select 1 from cost_centers where org_unit_id = v_id) then
raise exception 'Auf diese Einheit verweisen noch Kostenstellen.';
end if;
delete from org_units where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (app_current_user_id(), current_actor_name(), 'Organisationseinheit gelöscht',
v_unit.org_number || ' · ' || v_unit.name,
'Ohne untergeordnete Einheiten und ohne Planstellen');
end;
$$;
comment on function delete_org_unit(jsonb) is
'Entfernt eine Organisationseinheit, unter der nichts hängt und nie etwas hing. Einheiten mit Planstellen — auch geschlossenen — oder mit untergeordneten Einheiten werden abgewiesen; ein Schliessen zum Stichtag gibt es noch nicht, weil org_units nirgends gegen den Stichtag gelesen wird.';
grant execute on function delete_org_unit(jsonb) to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'delete_org_unit' and p.prokind = 'f';
if v_def is null then
raise exception 'delete_org_unit wurde nicht angelegt';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'delete_org_unit prüft die Rechte nicht';
end if;
if v_def not like '%search_path%' then
raise exception 'delete_org_unit hat keinen festen search_path';
end if;
if v_def not like '%audit_log%' then
raise exception 'delete_org_unit schreibt nichts ins Protokoll';
end if;
-- Die drei Abweisungen. Fällt eine bei einer Neuerzeugung weg, löscht die
-- Funktion weiter — nur mehr, als sie darf.
if v_def not like '%om_positions%' then
raise exception 'delete_org_unit prüft die Planstellen nicht';
end if;
if v_def not like '%parent_id = v_id%' then
raise exception 'delete_org_unit prüft die untergeordneten Einheiten nicht';
end if;
if v_def not like '%v_unit.parent_id is null%' then
raise exception 'delete_org_unit schützt die Wurzel nicht';
end if;
-- Das Gegenstück muss weiterhin dastehen: ein Löschen ohne Anlegen wäre
-- eine Einbahnstrasse.
if not exists (
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'create_org_unit'
) then
raise exception 'create_org_unit fehlt';
end if;
end
$$;

View File

@@ -0,0 +1,103 @@
-- Wiedereintritt: der Umwandlungsschritt beim Status, zum zweiten Mal
--
-- rehire_employee scheitert bei jedem Aufruf mit
--
-- column "status" is of type employment_status but expression is of type text
--
-- Der case-Ausdruck liefert `text` (beide Zweige sind Literale ohne Typ), die
-- Spalte ist ein Aufzählungstyp, und Postgres wandelt beim Zuweisen nicht von
-- selbst um. Genau dieser Fehler wurde am 10.08. mit
-- 20260810130000_fix_rehire_status_cast.sql behoben.
--
-- ═══ Warum er wieder da ist ═══════════════════════════════════════
--
-- Vier spätere Migrationen haben rehire_employee neu erzeugt und dabei den
-- Zusatz nicht mitgenommen: 20260917100000 (Austrittsart), 20260917130000
-- (Wiedereintritt mit Stammdaten), 20260923120000 (Firmen-E-Mail) und
-- 20260928100000 (Cornerstone-ID).
--
-- Die ersten beiden haben den Rumpf aus einer älteren Datei kopiert — der
-- Fall, vor dem der Abschnitt „create or replace aus veraltetem Muster" warnt.
-- Die letzten beiden sind aus der **laufenden** Definition erzeugt worden und
-- haben damit getreu weitergetragen, was seit dem 17.09. dort stand. Daraus
-- die Lehre, die vorher nicht dastand: das Erzeugen aus dem laufenden Stand
-- schützt davor, Verhalten zu **verlieren** — nicht davor, einen bereits
-- vorhandenen Fehler **mitzunehmen**. Dagegen hilft nur eine Selbstprüfung,
-- die das Erwartete benennt, statt nur das Neue zu prüfen.
--
-- Deshalb steht unten nicht bloss „der Zusatz ist da", sondern eine Prüfung,
-- die jede künftige Neuerzeugung dieser Funktion mitnehmen kann.
do $migration$
declare
v_alt constant text := $anker$status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,$anker$;
v_neu constant text := $anker$status = (case when v_date <= current_date then 'Aktiv' else 'Geplant' end)::employment_status,$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
if v_def is null then
raise exception 'rehire_employee ist nicht vorhanden.';
end if;
-- Schon in Ordnung? Dann nichts tun. Die Migration soll auch auf einer
-- Datenbank durchlaufen, auf der die Funktion aus einer künftigen Datei mit
-- Umwandlung entstanden ist.
if v_def like '%end)::employment_status%' then
raise notice 'rehire_employee wandelt den Status bereits um.';
return;
end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Die Statuszuweisung kommt % mal vor, erwartet genau einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- Selbstprüfung.
--
-- Geprüft wird die Umwandlung **und** alles, was an dieser Funktion schon
-- einmal verlorengegangen ist. Wer sie das nächste Mal neu erzeugt, kopiert
-- diesen Block am besten mit.
do $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
if v_def not like '%end)::employment_status%' then
raise exception 'rehire_employee wandelt den Status nicht um — der Wiedereintritt scheitert bei jedem Aufruf.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'rehire_employee prueft die Rechte nicht.';
end if;
if v_def not like '%search_path%' then
raise exception 'rehire_employee hat keinen festen search_path.';
end if;
-- Die Stammdaten, die der Wiedereintritt seit 20260917130000 mitschreibt.
-- Ohne sie zeigt der Assistent Felder an und wirft ihre Werte weg.
if v_def not like '%cornerstone_id%' then
raise exception 'rehire_employee hat die Cornerstone-ID verloren.';
end if;
if v_def not like '%company_email%' then
raise exception 'rehire_employee hat die Firmen-E-Mail verloren.';
end if;
if v_def not like '%title_prefix%' then
raise exception 'rehire_employee hat die Titel verloren.';
end if;
if v_def not like '%paygrade%' then
raise exception 'rehire_employee hat den Hay-Grade verloren.';
end if;
raise notice 'rehire_employee: Statusumwandlung und Stammdaten vollstaendig.';
end
$$;

View File

@@ -0,0 +1,177 @@
-- Versetzung und Beförderung am selben Tag, an dem die Besetzung begann
--
-- Gemeldet beim Nachtest am 29.09.: eine Beförderung auf eine andere
-- Planstelle scheiterte mit
--
-- new row for relation "position_assignments" violates check constraint
-- "chk_assignment_range"
--
-- Die Person war am selben Tag schon einmal versetzt worden. Ihre laufende
-- Besetzung begann also heute, und der Code schliesst sie auf denselben Tag:
--
-- update position_assignments set valid_to = <wirksam ab> where valid_to is null;
-- insert into position_assignments (…) values (…, <wirksam ab>);
--
-- `chk_assignment_range` verlangt `valid_to > valid_from`; ein Intervall von
-- null Tagen ist verboten. Das ist richtig so — und der Fall ist im Haus auch
-- schon einmal gelöst worden: terminate_employee **löscht** die Besetzung beim
-- Grund „No Show", statt sie auf [Eintritt, Eintritt) zu schliessen, mit der
-- Begründung, dass niemand sie je innehatte. Dieselbe Begründung gilt hier:
-- eine Besetzung, die am Tag ihres Beginns wieder endet, hat nicht
-- stattgefunden.
--
-- Die Alternative — die zweite Versetzung am selben Tag abzuweisen — wäre
-- genau der häufigste Fall: jemand hat die falsche Planstelle erwischt und
-- will es sofort richtigstellen. Die Ereignisse in employee_history bleiben
-- beide stehen; sichtbar ist also weiterhin, dass zweimal verschoben wurde.
--
-- Betroffen sind vier Stellen mit demselben Paar aus Schliessen und Einfügen:
-- transfer_employee, promote_employee und im Nachtlauf die Zweige `transfer`
-- und `promotion`. Alle vier werden aus der laufenden Definition gelesen und
-- an einem Anker ergänzt.
-- ═══ 1. transfer_employee ═════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_effective$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_employee_id and valid_to is null and valid_from >= v_effective;
update position_assignments set valid_to = v_effective$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
if v_def is null then
raise exception 'transfer_employee ist nicht vorhanden.';
end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 2. promote_employee ══════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_effective_date$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_employee_id and valid_to is null and valid_from >= v_effective_date;
update position_assignments set valid_to = v_effective_date$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
if v_def is null then
raise exception 'promote_employee ist nicht vorhanden.';
end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 3. Der Nachtlauf, beide Zweige ═══════════════════════════════
--
-- Hier steht dasselbe zweimal — bei `transfer` und bei `promotion` mit
-- Zielplanstelle. Beide werden ersetzt, und die Zahl wird geprüft: der Zweig
-- `transfer` ist in diesem Projekt schon einmal spurlos verschwunden.
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_rec.effective_date$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_rec.employee_id and valid_to is null and valid_from >= v_rec.effective_date;
update position_assignments set valid_to = v_rec.effective_date$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
if v_def is null then
raise exception 'apply_due_pending_changes ist nicht vorhanden.';
end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 2 then
raise exception 'Der Anker im Nachtlauf kommt % mal vor, erwartet zweimal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 4. Selbstprüfung ═════════════════════════════════════════════
do $$
declare
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective%' then
raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht ab.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'transfer_employee prueft die Rechte nicht.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective_date%' then
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht ab.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'promote_employee prueft die Rechte nicht.';
end if;
-- Was an dieser Funktion schon einmal verlorenging.
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle verloren.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
v_anzahl := (length(v_def) - length(replace(v_def, 'valid_from >= v_rec.effective_date', '')))
/ length('valid_from >= v_rec.effective_date');
if v_anzahl <> 2 then
raise exception 'Der Nachtlauf raeumt an % von 2 Stellen ab.', v_anzahl;
end if;
v_anzahl := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
/ length('insert into position_assignments');
if v_anzahl <> 2 then
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anzahl;
end if;
if v_def not like '%v_rec.payload->''role''%' then
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
end if;
if not exists (
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prosecdef
) then
raise exception 'Der Nachtlauf ist nicht mehr SECURITY DEFINER.';
end if;
raise notice 'Besetzung am selben Tag: alle vier Stellen abgeraeumt.';
end
$$;

View File

@@ -0,0 +1,250 @@
-- Eine Planstelle, auf die jemand vorgemerkt ist, ist nicht frei
--
-- Gemeldet im Test vom 29.09. (F.24): eine Person wurde auf eine Planstelle
-- eingestellt, auf die bereits eine Versetzung zum 20.10. vorgemerkt war. Die
-- Planstellenliste führte die Stelle als unbesetzt, der Assistent bot sie an,
-- und das Organigramm zum 20.10. zeigte sie zweifach besetzt.
--
-- ═══ Warum das schlimmer ist, als es aussieht ═════════════════════
--
-- Eine vorgemerkte Versetzung steht in pending_org_changes und noch nicht in
-- position_assignments. „Besetzt?" wird aber allein an position_assignments
-- gefragt — die Stelle ist zum Zeitpunkt der Buchung tatsächlich frei.
--
-- Auffallen würde es erst in der Nacht zum 20.10.: dann legt der Nachtlauf die
-- Besetzung der vorgemerkten Person an und läuft in
-- `position_assignments_one_holder`, den Teilindex, der genau eine laufende
-- Besetzung je Planstelle zulässt. Der Nachtlauf arbeitet in **einer**
-- Transaktion über alle fälligen Vorgänge; eine einzige solche Buchung bringt
-- ihn also vollständig zum Stehen — auch für alle anderen. Der Fehler entsteht
-- heute und schlägt Wochen später bei jemand anderem zu.
--
-- ═══ Was hier steht ═══════════════════════════════════════════════
--
-- Eine Auskunft, die pending_org_changes mitliest, und ihr Aufruf in den vier
-- Funktionen, die jemanden auf eine Planstelle setzen: hire_employee,
-- rehire_employee, transfer_employee, promote_employee.
--
-- Abgewiesen wird mit Datum — „ab dem 20.10.2026 vorgemerkt" sagt der Person
-- vor dem Bildschirm, was zu tun ist. Die eigene Vormerkung zählt nicht als
-- Hindernis: wer dieselbe Versetzung noch einmal erfasst, korrigiert sie.
create or replace function planstelle_vorgemerkt(p_position_id uuid, p_ab date, p_ausser uuid default null)
returns date language sql stable
set search_path = public, pg_temp
as $$
select min(o.effective_date)
from pending_org_changes o
where o.status = 'pending'
-- Nur die beiden Vorgänge, die eine Planstelle belegen. Eine
-- Vertragsänderung oder eine Karenz trägt keine Zielplanstelle.
and o.change_type in ('transfer', 'promotion')
and o.effective_date >= p_ab
and (p_ausser is null or o.employee_id <> p_ausser)
and nullif(o.payload->>'target_position_id', '')::uuid = p_position_id;
$$;
comment on function planstelle_vorgemerkt(uuid, date, uuid) is
'Ab wann eine Planstelle durch eine vorgemerkte Versetzung oder Beförderung belegt ist, oder null. Die Vormerkung der übergebenen Person zählt nicht mit.';
grant execute on function planstelle_vorgemerkt(uuid, date, uuid) to alpenwerk_app;
-- ═══ hire_employee ════════════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_entry, null);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' and p.prokind = 'f';
if v_def is null then raise exception 'hire_employee ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in hire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ rehire_employee ══════════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_date, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
if v_def is null then raise exception 'rehire_employee ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in rehire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ transfer_employee ════════════════════════════════════════════
--
-- Der Anker steht auf der zweiten Zeile der Belegungsabfrage. Die erste
-- Fassung dieser Migration zielte auf
--
-- where pa.position_id = v_target_position and pa.valid_to is null;
--
-- also auf den Wortlaut aus 20260727120200 — und fand nichts. Die Funktion ist
-- seither dynamisch gepatcht worden: die Abfrage ist zweizeilig und
-- berücksichtigt den Stichtag. Dieselbe Falle wie immer, nur diesmal nicht im
-- Rumpf einer Funktion, sondern im Anker auf sie.
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_target_position, v_effective, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
if v_def is null then raise exception 'transfer_employee ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ promote_employee ═════════════════════════════════════════════
--
-- Ohne Zielplanstelle ist v_position null; die Auskunft liefert dann nichts,
-- und der Zweig läuft durch. Eine Beförderung auf derselben Stelle braucht
-- deshalb keine Sonderbehandlung.
do $migration$
declare
v_alt constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;$anker$;
v_neu constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position, v_effective_date, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
if v_def is null then raise exception 'promote_employee ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ Selbstprüfung ════════════════════════════════════════════════
do $$
declare
v_name text;
v_def text;
begin
foreach v_name in array array['hire_employee', 'rehire_employee', 'transfer_employee', 'promote_employee'] loop
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = v_name and p.prokind = 'f';
if v_def not like '%planstelle_vorgemerkt(%' then
raise exception '% fragt die vorgemerkten Planstellen nicht ab.', v_name;
end if;
if v_def not like '%require_hr_admin()%' then
raise exception '% prueft die Rechte nicht.', v_name;
end if;
if v_def not like '%search_path%' then
raise exception '% hat keinen festen search_path.', v_name;
end if;
end loop;
-- Was an diesen Funktionen schon einmal verlorenging. Die Statusumwandlung
-- im Wiedereintritt ist zweimal verschwunden; sie wird hier mitgeprüft,
-- weil diese Migration die Funktion erneut erzeugt.
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
if v_def not like '%end)::employment_status%' then
raise exception 'rehire_employee wandelt den Status nicht um.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'rehire_employee hat die Cornerstone-ID verloren.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective_date%' then
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
end if;
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle verloren.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective%' then
raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
end if;
raise notice 'Vorgemerkte Planstellen: alle vier Buchungswege pruefen sie.';
end
$$;

View File

@@ -0,0 +1,76 @@
-- Austritt am Tag, an dem die Besetzung begann
--
-- Die fünfte Stelle mit demselben Paar aus Schliessen und Einfügen, gefunden
-- beim Schreiben des Rauchtests: terminate_employee schliesst die Besetzung auf
-- das Austrittsdatum. Wer heute eingestellt und heute wieder ausgetragen wird —
-- ohne den Grund „No Show" —, läuft damit in chk_assignment_range.
--
-- Die No-Show-Hälfte derselben Funktion macht es längst richtig und erklärt
-- auch, warum: eine Besetzung von null Tagen hat nicht stattgefunden, also wird
-- sie entfernt statt geschlossen. Nur galt das bisher für genau einen
-- Austrittsgrund. Ein Eintritt am selben Tag zurückgenommen, aber mit
-- „Einvernehmliche Auflösung" statt „No Show", scheiterte an der rohen
-- Datenbankmeldung.
--
-- Damit sind es fünf: hire/rehire brauchen es nicht (sie legen an, sie
-- schliessen nicht), transfer_employee, promote_employee und die beiden Zweige
-- des Nachtlaufs stehen seit 20260929180000, terminate_employee kommt hier
-- dazu.
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_exit$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_employee_id and valid_to is null and valid_from >= v_exit;
update position_assignments set valid_to = v_exit$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'terminate_employee' and p.prokind = 'f';
if v_def is null then raise exception 'terminate_employee ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in terminate_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- Selbstprüfung: das Neue und alles, was an dieser Funktion schon einmal
-- verlorengegangen ist. Die Anmerkung ist beim Umbau auf No-Show einmal
-- stillschweigend verschwunden und fiel erst durch eine Frage des Kunden auf.
do $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'terminate_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_exit%' then
raise exception 'terminate_employee raeumt die Besetzung vom selben Tag nicht ab.';
end if;
if v_def not like '%No Show%' then
raise exception 'terminate_employee kennt den Nichtantritt nicht mehr.';
end if;
if v_def not like '%v_note%' then
raise exception 'terminate_employee schreibt die Anmerkung nicht.';
end if;
if v_def not like '%austrittsart%' then
raise exception 'terminate_employee schreibt die Austrittsart nicht.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'terminate_employee prueft die Rechte nicht.';
end if;
if v_def not like '%search_path%' then
raise exception 'terminate_employee hat keinen festen search_path.';
end if;
raise notice 'terminate_employee: Austritt am selben Tag abgeraeumt.';
end
$$;

View File

@@ -0,0 +1,75 @@
-- Keine Vertragsänderung über nichts
--
-- Gemeldet im Test vom 29.09. (H.06): jedes Speichern in „Daten ändern"
-- erzeugte zusätzlich eine Vertragsänderung „Wochenstunden 30.0 → 30", auch
-- wenn niemand die Stunden angefasst hatte.
--
-- Der Vergleich läuft über Text. Die Spalte ist numeric(4,1), also liefert
-- `v_old.weekly_hours::text` den Wert „30.0". Das Formular schickt die Zahl
-- 30, und `('30')::numeric::text` ergibt „30" — numeric merkt sich die
-- Nachkommastellen der Eingabe. Zwei verschiedene Zeichenketten für dieselbe
-- Zahl.
--
-- Der Kommentar an dieser Zeile sagt „Über ::numeric::text, damit 38.50 und
-- 38.5 gleich zählen" — die Absicht stimmte, nur reicht `::numeric` dafür
-- nicht. Die Umwandlung muss auf **die Genauigkeit der Spalte** gehen, dann
-- steht auf beiden Seiten dieselbe Schreibweise.
--
-- Folgen bisher: Historie und der Ereignisbericht „Vertragsänderung" füllten
-- sich mit Einträgen über nichts, und im Nachtlauf hing an jeder solchen
-- Änderung ein geplanter Vorgang, der nichts tat.
do $migration$
declare
v_alt constant text := $anker$(nullif(v_contract->>'weekly_hours','')::numeric)::text$anker$;
v_neu constant text := $anker$(nullif(v_contract->>'weekly_hours','')::numeric(4,1))::text$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
if v_def is null then raise exception 'change_employee_data ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Vergleich der Wochenstunden kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- Selbstprüfung: das Neue und das, was an dieser Funktion schon einmal
-- verlorengegangen ist.
do $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
if v_def not like '%::numeric(4,1))::text%' then
raise exception 'change_employee_data vergleicht die Wochenstunden weiter ohne Genauigkeit.';
end if;
if v_def not like '%''Hay-Grade''%' then
raise exception 'change_employee_data protokolliert den Hay-Grade nicht mehr.';
end if;
if v_def not like '%nullif(v_person->>''email'', '''')%' then
raise exception 'change_employee_data hat die Leerstring-Behandlung verloren.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'change_employee_data hat die Cornerstone-ID verloren.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'change_employee_data prueft die Rechte nicht.';
end if;
if v_def not like '%search_path%' then
raise exception 'change_employee_data hat keinen festen search_path.';
end if;
raise notice 'Wochenstunden werden mit der Genauigkeit der Spalte verglichen.';
end
$$;

View File

@@ -0,0 +1,252 @@
-- Eine Beförderung benennt die Planstelle um
--
-- Bisher schrieb promote_employee den neuen Titel nach employees.job_title —
-- und **nur** dorthin. Gesehen hat ihn niemand: Akte, Organigramm und die
-- Exportdateien zeigen alle die Tätigkeit der Planstelle. Schlimmer noch, der
-- Wert war nicht einmal haltbar: hire_employee, transfer_employee und
-- change_employee_data setzen dieselbe Spalte aus der Planstelle, also
-- überschrieb die nächste Adressänderung den Titel wieder.
--
-- Gemeldet von Lara („in der zusammenfassung der person ist jedoch noch
-- Marketingtätigkeit angeführt") und im Testprotokoll als H.10. Max hat am
-- 30.09. entschieden: die Beförderung benennt die Planstelle um.
--
-- ═══ Welche Planstelle ════════════════════════════════════════════
--
-- Die, auf der die Person **danach** sitzt: die Zielplanstelle, wenn eine
-- gewählt wurde, sonst die bisherige. Ein Titel, der zu keiner Stelle gehört,
-- war ja gerade das Problem.
--
-- Im Formular ist das ohne Überraschung, weil es beim Wählen einer
-- Zielplanstelle deren Tätigkeit als Vorschlag einträgt: wer nichts ändert,
-- benennt auch nichts um.
--
-- ═══ Der Katalog ═════════════════════════════════════════════════
--
-- Umbenannt wird nicht der Katalogeintrag, sondern die Planstelle zeigt auf
-- einen anderen. Den Eintrag selbst umzubenennen träfe **jede** Planstelle mit
-- derselben Tätigkeit — bei Manner hängen an einem Eintrag bis zu fünf.
--
-- Die Regel „gleiche Tätigkeit, ein Katalogeintrag" stand bisher nur in
-- create_position. Sie wandert in job_fuer_titel() und wird von dort aus
-- benutzt; zwei Fassungen derselben Regel laufen in diesem Projekt
-- erfahrungsgemäss auseinander.
create or replace function job_fuer_titel(p_titel text)
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_titel text := nullif(btrim(p_titel), '');
v_id uuid;
begin
if v_titel is null then
raise exception 'Es muss eine Tätigkeit angegeben werden.';
end if;
-- Ohne Rücksicht auf Gross- und Kleinschreibung: sonst stehen
-- „Schlosser:in" und „schlosser:in" nebeneinander und jede Auswertung nach
-- Tätigkeit ist wertlos.
select id into v_id from jobs where lower(title) = lower(v_titel);
if v_id is not null then
return v_id;
end if;
-- Die erste freie Nummer statt count(*) + 1. Gezählt wurde bisher, und das
-- vergibt eine bereits belegte Nummer, sobald ein Eintrag gelöscht wurde
-- oder — wie bei der Übernahme der Manner-Daten — Codes aus einer fremden
-- Quelle danebenstehen.
insert into jobs (code, title)
select 'J' || lpad(n::text, 4, '0'), v_titel
from generate_series(1, 100000) n
where not exists (select 1 from jobs j where j.code = 'J' || lpad(n::text, 4, '0'))
order by n
limit 1
returning id into v_id;
if v_id is null then
raise exception 'Es ist keine freie Jobnummer mehr frei.';
end if;
return v_id;
end;
$$;
comment on function job_fuer_titel(text) is
'Der Katalogeintrag zu einer Tätigkeit — vorhandener oder neuer. Eine Tätigkeit, ein Eintrag, ohne Rücksicht auf Gross- und Kleinschreibung.';
grant execute on function job_fuer_titel(text) to alpenwerk_app;
-- ═══ 1. create_position benutzt die Auskunft statt einer zweiten Fassung ══
do $migration$
declare
v_alt constant text := $anker$ select id into v_job_id from jobs where lower(title) = lower(v_job_title);
if v_job_id is null then
insert into jobs (code, title)
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
returning id into v_job_id;
end if;$anker$;
v_neu constant text := $anker$ v_job_id := job_fuer_titel(v_job_title);$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'create_position' and p.prokind = 'f';
if v_def is null then raise exception 'create_position ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in create_position kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 2. promote_employee benennt die Planstelle um ════════════════
do $migration$
declare
v_alt constant text := $anker$ update employees set
job_title = payload->>'new_title',
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;$anker$;
v_neu constant text := $anker$ update employees set
job_title = payload->>'new_title',
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
-- Die Bezeichnung gehört zur Planstelle, nicht zur Person: dort lesen sie
-- Akte, Organigramm und die Exporte. Betroffen ist die Stelle, auf der die
-- Person danach sitzt — die Zielstelle, sonst die bisherige.
declare
v_umbenennen uuid := coalesce(v_position, (
select pa.position_id from position_assignments pa
where pa.employee_id = v_employee_id and pa.valid_to is null));
begin
if v_umbenennen is not null then
update om_positions set job_id = job_fuer_titel(payload->>'new_title')
where id = v_umbenennen;
end if;
end;$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
if v_def is null then raise exception 'promote_employee ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 3. Der Nachtlauf, Zweig `promotion` ══════════════════════════
--
-- Eine auf später datierte Beförderung muss dasselbe tun, sonst benennt sie
-- am Stichtag nichts um und der Titel wäre wieder nur an der Person — genau
-- der Zustand, den diese Migration beendet.
do $migration$
declare
v_alt constant text := $anker$ update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;$anker$;
v_neu constant text := $anker$ update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
if nullif(v_rec.payload->>'new_title', '') is not null then
declare
v_umbenennen uuid := coalesce(
nullif(v_rec.payload->>'target_position_id', '')::uuid, (
select pa.position_id from position_assignments pa
where pa.employee_id = v_rec.employee_id and pa.valid_to is null));
begin
if v_umbenennen is not null then
update om_positions set job_id = job_fuer_titel(v_rec.payload->>'new_title')
where id = v_umbenennen;
end if;
end;
end if;$anker$;
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
if v_def is null then raise exception 'apply_due_pending_changes ist nicht vorhanden.'; end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker im Nachtlauf kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ Selbstprüfung ════════════════════════════════════════════════
do $$
declare
v_def text;
v_anzahl int;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'create_position' and p.prokind = 'f';
if v_def not like '%job_fuer_titel(%' then
raise exception 'create_position benutzt die gemeinsame Auskunft nicht.';
end if;
if v_def like '%count(*) + 1 from jobs%' then
raise exception 'create_position traegt noch die zweite Fassung der Katalogregel.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'create_position prueft die Rechte nicht.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
if v_def not like '%update om_positions set job_id = job_fuer_titel%' then
raise exception 'promote_employee benennt die Planstelle nicht um.';
end if;
-- Was an dieser Funktion schon einmal verlorenging.
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle verloren.';
end if;
if v_def not like '%valid_from >= v_effective_date%' then
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
end if;
if v_def not like '%planstelle_vorgemerkt(%' then
raise exception 'promote_employee prueft die vorgemerkten Planstellen nicht mehr.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'promote_employee prueft die Rechte nicht.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
if v_def not like '%job_fuer_titel(v_rec.payload%' then
raise exception 'Der Nachtlauf benennt die Planstelle nicht um.';
end if;
v_anzahl := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
/ length('insert into position_assignments');
if v_anzahl <> 2 then
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anzahl;
end if;
if v_def not like '%v_rec.payload->''role''%' then
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
end if;
raise notice 'Befoerderung benennt die Planstelle um — sofort und im Nachtlauf.';
end
$$;

223
db/tests/rauchtest.sql Normal file
View File

@@ -0,0 +1,223 @@
-- Rauchtest: der Lebenszyklus, einmal wirklich ausgeführt
--
-- docker compose run --rm psql -f /repo/db/tests/rauchtest.sql
--
-- ═══ Warum es das gibt ════════════════════════════════════════════
--
-- rehire_employee war seit dem 17.09. bei jedem Aufruf kaputt: ein fehlender
-- Umwandlungsschritt beim Status. Aufgefallen ist es erst beim Handtest am
-- 29.09. Keine Prüfung der Anwendung hat es bemerkt, und keine konnte es:
-- die Selbstprüfungen der Migrationen lesen den **Text** der Funktionen, die
-- Unit-Tests laufen ohne Datenbank, und die Integrationstests haben keinen
-- Bestand, gegen den sie liefen.
--
-- Diese Datei schliesst genau diese Lücke: sie **ruft** die Funktionen auf,
-- gegen die echte Datenbank, in der Reihenfolge des Lebenszyklus.
--
-- ═══ Zwei Eigenschaften, ohne die es gefährlich wäre ══════════════
--
-- 1. **Es wird zurückgerollt.** Alles läuft in einer Transaktion, die am Ende
-- verworfen wird. Nach dem Lauf steht kein Testmensch, keine Testplanstelle
-- und keine Testeinheit in der Datenbank. Der Anlass steht daneben: beim
-- Test vom 29.09. sind reale Personen auf Testplanstellen umgezogen und
-- dort geblieben.
--
-- 2. **Es läuft als Anwendungsrolle.** psql verbindet sich als Superuser, und
-- der geht an jeder Zeilenschutz-Regel und an require_hr_admin() vorbei.
-- Ein Lauf als postgres würde beweisen, dass die Funktionen für niemanden
-- funktionieren, der sie benutzt.
--
-- Fehlschläge brechen mit einer Meldung ab, die den Schritt nennt. Kommt der
-- Lauf bis „Rauchtest bestanden" durch, ist der Lebenszyklus gegangen worden.
\set ON_ERROR_STOP on
begin;
select set_config(
'app.user_id',
(select p.id::text from profiles p where p.role = 'hr' and p.is_active limit 1),
true
) as angemeldet_als;
set local role alpenwerk_app;
do $rauchtest$
declare
v_wurzel uuid;
v_einheit uuid;
v_p1 uuid; v_p2 uuid; v_p3 uuid; v_p4 uuid;
v_a uuid; v_b uuid;
v_ort uuid;
v_nr int;
v_status text;
v_stelle uuid;
v_offen int;
v_meldung text;
v_ging boolean;
v_titel text;
begin
select id into v_wurzel from org_units where parent_id is null;
select id into v_ort from locations order by name limit 1;
select coalesce(max(personnel_number), 0) + 1 into v_nr from employees;
if v_wurzel is null or v_ort is null then
raise exception 'Kein Bestand: es fehlt die oberste Einheit oder ein Standort.';
end if;
-- ── Spielwiese ──────────────────────────────────────────────────
v_einheit := create_org_unit(jsonb_build_object(
'parent_id', v_wurzel, 'org_number', 'RAUCHTEST', 'name', 'Rauchtest',
'unit_type', 'Team', 'valid_from', (current_date - 30)::text));
v_p1 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Eins', 'is_chief', false, 'valid_from', (current_date - 30)::text));
v_p2 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Zwei', 'is_chief', false, 'valid_from', (current_date - 30)::text));
v_p3 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Drei', 'is_chief', false, 'valid_from', (current_date - 30)::text));
v_p4 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Vier', 'is_chief', false, 'valid_from', (current_date - 30)::text));
raise notice '1/8 Einheit und vier Planstellen angelegt';
-- ── Einstellung ─────────────────────────────────────────────────
v_a := hire_employee(jsonb_build_object(
'personnel_number', v_nr, 'first_name', 'Rauch', 'last_name', 'Test-A',
'gender', 'w', 'birth_date', '1990-01-01', 'location_id', v_ort,
'position_id', v_p1, 'entry_date', current_date::text));
raise notice '2/8 Einstellung auf Planstelle 1';
-- ── Versetzung am selben Tag (20260929180000) ───────────────────
-- Die Besetzung begann heute; sie auf heute zu schliessen wäre ein
-- Intervall von null Tagen. Vorher: chk_assignment_range.
perform transfer_employee(jsonb_build_object(
'employee_id', v_a, 'target_position_id', v_p2, 'effective_date', current_date::text));
select position_id into v_stelle from position_assignments
where employee_id = v_a and valid_to is null;
if v_stelle is distinct from v_p2 then
raise exception 'Nach der Versetzung sitzt die Person nicht auf Planstelle 2.';
end if;
raise notice '3/8 Versetzung am selben Tag';
-- ── Beförderung mit Planstellenwechsel, ebenfalls heute ─────────
perform promote_employee(jsonb_build_object(
'employee_id', v_a, 'effective_date', current_date::text,
'new_title', 'Rauchtest Leitung', 'new_paygrade', 'HG15',
'target_position_id', v_p3));
select position_id into v_stelle from position_assignments
where employee_id = v_a and valid_to is null;
if v_stelle is distinct from v_p3 then
raise exception 'Nach der Beförderung sitzt die Person nicht auf Planstelle 3.';
end if;
if (select paygrade::text from employees where id = v_a) <> 'HG15' then
raise exception 'Die Beförderung hat den Hay-Grade nicht gesetzt.';
end if;
-- Die Bezeichnung gehört zur Planstelle: dort lesen sie Akte, Organigramm
-- und die Exporte. Vorher stand der neue Titel allein an der Person und war
-- nirgends zu sehen.
select j.title into v_titel
from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3;
if v_titel <> 'Rauchtest Leitung' then
raise exception 'Die Zielplanstelle heisst nach der Beförderung „%", erwartet „Rauchtest Leitung".', v_titel;
end if;
raise notice '4/8 Beförderung am selben Tag: Hay-Grade und Planstellenbezeichnung';
-- Dieselbe Planstelle, nur ein neuer Name — der Fall aus dem Testtag.
perform promote_employee(jsonb_build_object(
'employee_id', v_a, 'effective_date', current_date::text,
'new_title', 'Rauchtest Leitung Senior', 'new_paygrade', 'HG16'));
select j.title into v_titel
from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3;
if v_titel <> 'Rauchtest Leitung Senior' then
raise exception 'Ohne Stellenwechsel heisst die Planstelle „%", erwartet „Rauchtest Leitung Senior".', v_titel;
end if;
-- Der Katalogeintrag der alten Bezeichnung bleibt stehen: an ihm können
-- andere Planstellen hängen. Umbenannt wird die Stelle, nicht der Katalog.
if not exists (select 1 from jobs where title = 'Rauchtest Leitung') then
raise exception 'Der alte Katalogeintrag wurde umbenannt statt die Planstelle umgehängt.';
end if;
raise notice '4b/8 Beförderung ohne Stellenwechsel benennt dieselbe Planstelle um';
-- ── Vorgemerkte Planstelle (20260929200000) ─────────────────────
-- Eine Versetzung in die Zukunft belegt die Zielstelle, obwohl dort noch
-- keine Besetzung steht. Wer sie vorher besetzt, bringt den Nachtlauf zum
-- Stehen — vorher ging genau das durch.
perform transfer_employee(jsonb_build_object(
'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 10)::text));
-- Der Fehlschlag wird **ausserhalb** des Blocks ausgewertet. Ein `raise`
-- innerhalb landet im eigenen exception-Zweig — und weil seine Meldung das
-- Wort „vorgemerkte" enthielt, bestand der Schritt beim ersten Lauf, obwohl
-- die Migration gar nicht angewendet war. Ein Test, der sich selbst
-- durchwinkt, ist schlimmer als keiner.
v_ging := false;
begin
perform hire_employee(jsonb_build_object(
'personnel_number', v_nr + 1, 'first_name', 'Rauch', 'last_name', 'Test-B',
'gender', 'm', 'birth_date', '1990-01-01', 'location_id', v_ort,
'position_id', v_p4, 'entry_date', current_date::text));
v_ging := true;
exception
when others then v_meldung := SQLERRM;
end;
if v_ging then
raise exception 'Die vorgemerkte Planstelle liess sich besetzen — die Pruefung greift nicht.';
end if;
if v_meldung not like '%bereits eine Versetzung vorgemerkt%' then
raise exception 'Erwartet wurde die Meldung zur Vormerkung, kam aber: %', v_meldung;
end if;
raise notice '5/8 Vorgemerkte Planstelle wird abgewiesen';
-- Die eigene Vormerkung darf nicht im Weg stehen: dieselbe Versetzung noch
-- einmal zu erfassen ist eine Berichtigung.
perform transfer_employee(jsonb_build_object(
'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 20)::text));
raise notice '6/8 Die eigene Vormerkung steht nicht im Weg';
-- ── Austritt am selben Tag (20260929220000) ─────────────────────
perform terminate_employee(jsonb_build_object(
'employee_id', v_a, 'exit_date', current_date::text,
'exit_reason', 'Einvernehmliche Auflösung', 'austrittsart', 'freiwillig'));
select count(*) into v_offen from position_assignments
where employee_id = v_a and valid_to is null;
if v_offen <> 0 then
raise exception 'Nach dem Austritt ist die Planstelle nicht frei.';
end if;
raise notice '7/8 Austritt am selben Tag, Planstelle frei';
-- ── Wiedereintritt (20260929160000, K.01) ───────────────────────
perform rehire_employee(jsonb_build_object(
'employee_id', v_a, 'rehire_date', current_date::text, 'position_id', v_p1,
'first_name', 'Rauch', 'last_name', 'Test-A', 'gender', 'w',
'birth_date', '1990-01-01', 'nationality', 'Österreich', 'location_id', v_ort));
select status::text into v_status from employees where id = v_a;
if v_status <> 'Aktiv' then
raise exception 'Nach dem Wiedereintritt steht der Status auf %, erwartet Aktiv.', v_status;
end if;
select position_id into v_stelle from position_assignments
where employee_id = v_a and valid_to is null;
if v_stelle is distinct from v_p1 then
raise exception 'Nach dem Wiedereintritt sitzt die Person nicht auf Planstelle 1.';
end if;
raise notice '8/8 Wiedereintritt';
raise notice '';
raise notice 'Rauchtest bestanden. Alles Angelegte wird gleich verworfen.';
end
$rauchtest$;
rollback;
-- Beweis, dass nichts geblieben ist. Läuft ausserhalb der verworfenen
-- Transaktion und muss überall 0 liefern.
select
(select count(*) from org_units where org_number = 'RAUCHTEST') as einheiten,
(select count(*) from employees where last_name like 'Test-%' and first_name = 'Rauch') as personen,
(select count(*) from jobs where title like 'Rauchtest %') as taetigkeiten;

View File

@@ -108,7 +108,7 @@ die kommt über die Planstelle.
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
| `paygrade` | `paygrade_type` | – | `B` | A–F |
| `paygrade` | `paygrade_type` | – | `-` | Hay-Grade; der Spaltenname ist geblieben |
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
@@ -304,7 +304,7 @@ eigenen Zeilen sind ohnehin immer dabei. Die Tabelle hiess bis September 2026
| `contract_type` | unbefristet, befristet |
| `worker_type` | Angestellte:r, Arbeiter:in, Lehrling |
| `collective_agreement` | Handel, Süßwaren |
| `paygrade_type` | A, B, C, D, E, F |
| `paygrade_type` | -, HG09…HG19, HG19P, HG20 (Domain über text, kein Aufzählungstyp) |
| `source_type` | Intern, Extern |
| `gender_type` | m, w |
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |

View File

@@ -50,7 +50,20 @@ export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzei
* geregelte Teilzeiten, die man später wiederfinden können muss — im
* Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken.
*/
export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const;
export const STUNDEN_GRUENDE = [
"Vertragliche Stundenänderung",
"Bildungsteilzeit",
"Pflegeteilzeit",
// Die beiden standen zuerst nur in RUECKKEHR_GRUENDE, weil sie typischerweise
// dann beginnen, wenn eine Abwesenheit endet. Typischerweise ist aber nicht
// immer: eine Elternteilzeit kann auch vereinbart werden, ohne dass in
// diesem System je eine Karenz stand — etwa beim zweiten Elternteil oder
// wenn die Karenz vor der Einführung lag. Gemeldet von Lara am 29.09.:
// „Teilzeitvariante Elternteilzeit fehlt". Der Bericht konnte schon immer
// nach allen vier filtern; erfassen liessen sich nur zwei.
"Elternteilzeit",
"Wiedereingliederungsteilzeit",
] as const;
export type StundenGrund = (typeof STUNDEN_GRUENDE)[number];

View File

@@ -99,10 +99,13 @@ export function csDatum(iso: string | null | undefined): string {
}
/**
* Der Anmeldename: vorname.nachname, klein, ohne Umlaute und Leerzeichen.
* Der Konto­name im Verzeichnisdienst: vorname.nachname, klein, ohne Umlaute.
*
* Nur für das Customfield AD. Der **Username** in Cornerstone ist etwas
* anderes: er ist die UUID aus Alpenwerk, dieselbe wie die User ID.
*
* Abgeleitet und nicht aus der dienstlichen Adresse geschnitten: die Adresse
* ist freiwillig, der Name ist es nicht. Cornerstone braucht das Feld.
* ist freiwillig, der Name ist es nicht.
*/
export function benutzername(vorname: string, nachname: string): string {
const sauber = (s: string) =>
@@ -157,6 +160,7 @@ export type CornerstoneQuelle = {
title_suffix: string[];
gender: string;
company_email: string | null;
cornerstone_id: string | null;
address: string | null;
postal_code: string | null;
city: string | null;
@@ -174,31 +178,60 @@ export type CornerstoneQuelle = {
};
export type CornerstoneKontext = {
/** Personalnummer je Mitarbeiterkennung — Cornerstone verweist über die User ID. */
managerNummer: Map<string, number>;
/** Cornerstone-ID je Mitarbeiterkennung — der Verweis auf die vorgesetzte Person. */
managerKennung: Map<string, string | null>;
/** Kostenstelle je Planstelle zum Stichtag. */
kostenstelle: Map<string, Kostenstelle>;
};
/** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */
export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile {
const nummer = p.personnel_number;
const name = benutzername(p.first_name, p.last_name);
// Zwei Kennungen, zwei Herkünfte:
//
// User ID + Username die Cornerstone-ID (employees.cornerstone_id)
// Local System ID die Personalnummer, wie LOGA sie vergibt
//
// Benutzername und Benutzer-ID sind stets derselbe Wert — liefen sie
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
// jeder Verweis darauf träfe womöglich die falsche.
//
// Hier stand die Alpenwerk-UUID, solange es nichts Besseres gab. Seit
// 20260928100000 führt Alpenwerk die Kennung mit, unter der Cornerstone
// die Person selbst kennt; die UUID sagte dort niemandem etwas.
//
// **Leer, wenn keine hinterlegt ist.** Ein Rückfall auf die UUID brächte
// zwei Kennungsarten in eine Datei, ohne dass es jemandem auffiele — und
// legte in Cornerstone eine zweite Person neben der bestehenden an. Eine
// fehlende Kennung ist eine fehlende Angabe und soll als solche auffallen.
//
// Der Anmeldename des Verzeichnisdienstes steht nur im Customfield AD.
const kennung = p.cornerstone_id ?? "";
const nummer = String(p.personnel_number);
const adName = benutzername(p.first_name, p.last_name);
const kst = p.position_id ? k.kostenstelle.get(p.position_id) : undefined;
const chef = p.manager_id ? k.managerNummer.get(p.manager_id) : undefined;
// Der Verweis auf die vorgesetzte Person geht über **deren** User ID, also
// über ihre Cornerstone-ID. Hier stand die Personalnummer: die ist in dieser
// Datei die Local System ID, und Cornerstone hätte den Verweis entweder ins
// Leere laufen lassen oder — schlimmer — auf jemand anderen gelegt, dessen
// User ID zufällig so aussieht wie eine Personalnummer.
//
// Leer, wenn die vorgesetzte Person selbst keine Cornerstone-ID trägt. Aus
// demselben Grund wie oben: eine Kennung der falschen Art ist schlimmer als
// keine, weil niemand ihr ansieht, dass sie falsch ist.
const chef = p.manager_id ? k.managerKennung.get(p.manager_id) : undefined;
const ausgetreten = p.status === "Ausgetreten";
return {
"User ID": String(nummer),
"Local System ID": String(nummer),
"User ID": kennung,
"Local System ID": nummer,
Prefix: p.title_prefix.join(" "),
"First Name": p.first_name,
"Middle Name": "",
"Last Name": p.last_name,
Suffix: p.title_suffix.join(" "),
Username: name,
Username: kennung,
Approver: "",
Manager: chef ? String(chef) : "",
Manager: chef ?? "",
Absent: "",
"Allow Reconciliation": "",
Email: p.company_email ?? "",
@@ -250,6 +283,10 @@ export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext
// Cornerstone-Position-ID. Sie hier einzutragen hiesse, eine Kennung des
// Zielsystems zu erfinden.
"Position ID": "",
// Der Hay-Grade, so wie er gespeichert ist. Wer keinen trägt, bekommt den
// „Generic Grade" — und der ist laut der Tabelle des Kunden der
// Bindestrich, nicht die leere Zelle. Hier wird deshalb nichts übersetzt;
// siehe lib/hay-grade.ts.
"Grade ID": p.paygrade,
"Cost Center ID": kst?.code ?? "",
// Leer: Cornerstone erwartet seine eigene Standortkennung („01"), und
@@ -261,7 +298,7 @@ export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext
// Kopfzeile, weil die Vorlage sie führt.
"Retention Rules": "",
Organisationsstufe: "",
"Customfield ID AD": name,
"Customfield ID AD": adName,
"Customfield ID Doxis": `${DOXIS_PRAEFIX}${nummer}`,
"Customfield ID Interflex": String(nummer),
"Customfield ID LGVplus": `${LGV_PRAEFIX}${nummer}`,

View File

@@ -22,13 +22,22 @@ export type DashboardParams = {
userId: string | null;
today: string;
yearStart: string;
yearEnd: string;
/**
* Das obere Ende des YTD-Fensters — **heute**, nicht der 31.12.
*
* Hier stand das Jahresende, womit die Kacheln „Eintritte/Austritte (YTD)"
* auch zählten, was erst bevorsteht: ein für den 01.11. erfasster Austritt
* stand schon im September als geschehen da. „Year to date" heisst bis
* heute, und die Kachel soll sagen, was war — was kommt, steht in
* „Anstehend" daneben. Gemeldet im Test vom 29.09. (B.02).
*/
ytdBis: string;
bisIso: string;
arten: AnstehendArt[];
};
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
const { userId, today, yearStart, ytdBis, bisIso, arten } = p;
const zeigt = (art: AnstehendArt) => arten.includes(art);
// Elf Abfragen standen hier in einem Promise.all, das keine
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
@@ -43,7 +52,7 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd);
.where("event_date", "<=", ytdBis);
const g = await tx
.selectNoFrom((eb) => [

View File

@@ -8,6 +8,24 @@
// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit
// Trennzeichen als Wortgrenze.
/**
* Die Zeichen, die im Namen als Wortgrenze zählen.
*
* Sie stehen hier und nicht in der Seite, weil **beide Seiten des Vergleichs**
* sie brauchen: das Feld wird per `translate` an diesen Zeichen in Leerzeichen
* zerlegt, und die Eingabe muss genauso zerlegt werden. Vorher tat es nur das
* Feld — wer „Müller-Weiß" mit Bindestrich suchte, erzeugte das Muster
* `müller-weiß%`, während im Heuhaufen „müller weiß" stand. Null Treffer,
* obwohl die Person da ist; ohne Bindestrich fand man sie. Gemeldet im Test
* vom 29.09. (C.08).
*/
export const TRENNZEICHEN = "-/:.,";
/** Für `translate(…, TRENNZEICHEN, TRENNZEICHEN_ERSATZ)`: gleich lang, nur Leerzeichen. */
export const TRENNZEICHEN_ERSATZ = " ".repeat(TRENNZEICHEN.length);
const WORTGRENZE = new RegExp(`[\\s${TRENNZEICHEN.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}]+`);
/** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */
export function istPersonalnummer(term: string): boolean {
return /^\d+$/.test(term.trim());
@@ -25,7 +43,10 @@ export function istPersonalnummer(term: string): boolean {
export function suchMuster(term: string): string[][] {
return term
.trim()
.split(/\s+/)
// An denselben Zeichen zerlegt wie das Feld, gegen das verglichen wird:
// „Müller-Weiß" wird zu zwei Wörtern, die beide am Wortanfang treffen
// müssen — genau wie „Müller Weiß".
.split(WORTGRENZE)
.filter(Boolean)
.map((wort) => {
// Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text.

View File

@@ -58,8 +58,8 @@ export async function ladeExportMitarbeiter(
lookups: OrgLookups;
orgMaps: OrgMaps;
managerName: Map<string, string>;
/** Personalnummer je Kennung — für Exporte, die über Nummern verweisen. */
managerNummer: Map<string, number>;
/** Cornerstone-ID je Kennung — für Exporte, die über die Kennung des Zielsystems verweisen. */
managerKennung: Map<string, string | null>;
dependentsCounts: Map<string, number>;
}> {
const statuses = parseStatuses(filters.status);
@@ -86,7 +86,9 @@ export async function ladeExportMitarbeiter(
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
employeeQuery().execute(),
loadOrgLookups(tx),
tx.selectFrom("employees").select(["id", "first_name", "last_name", "personnel_number"]).orderBy("id").execute(),
// Absichtlich schmal: die Liste umfasst **alle** Personen, nicht nur die
// gefilterten, und dient nur dazu, einen Verweis aufzulösen.
tx.selectFrom("employees").select(["id", "first_name", "last_name", "cornerstone_id"]).orderBy("id").execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf: stichtag }),
loadReportingLineMap(tx, stichtag),
@@ -105,6 +107,13 @@ export async function ladeExportMitarbeiter(
return [
{
...e,
// Die Tätigkeit der Planstelle, auf der die Person am Stichtag sitzt —
// und nur ersatzweise die Spalte an der Person. Genau so löst die
// Berichtsseite es auf (loadSnapshotEmployees); vorher stand hier die
// Spalte allein, und damit konnte in der Akte eine andere Position
// stehen als in der Datei daneben, ohne dass eines von beidem falsch
// aussah.
job_title: placement?.current ? placement.jobTitle : e.job_title,
org_unit_id: orgUnitId,
position_id: placement?.current ? placement.positionId : null,
position_number: placement?.positionNumber ?? null,
@@ -119,7 +128,7 @@ export async function ladeExportMitarbeiter(
lookups: lookupResult.lookups,
orgMaps,
managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])),
managerNummer: new Map(allEmployees.map((e) => [e.id, e.personnel_number])),
managerKennung: new Map(allEmployees.map((e) => [e.id, e.cornerstone_id])),
dependentsCounts,
};
}

View File

@@ -23,9 +23,10 @@ export function sanitizeForSpreadsheetCell(text: string): string {
return /^[=+\-@]/.test(text) ? `'${text}` : text;
}
function csvCell(value: string | number | boolean | null, trenner: string): string {
function csvCell(value: string | number | boolean | null, trenner: string, formelschutz: boolean): string {
if (value === null || value === undefined) return "";
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
const roh = typeof value === "boolean" ? (value ? "Ja" : "Nein") : String(value);
const text = formelschutz ? sanitizeForSpreadsheetCell(roh) : roh;
// Das Trennzeichen gehört in die Prüfung: mit Komma als Trenner muss ein
// Komma im Wert maskiert werden, sonst zerfällt die Zeile in zwei Spalten.
return text.includes(trenner) || /["\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
@@ -39,15 +40,22 @@ function csvCell(value: string | number | boolean | null, trenner: string): stri
* ankommen. Ein fremdes System will oft das Gegenteil — Cornerstone etwa
* erwartet Kommas, und ein BOM machte dort aus der ersten Spalte eine, die in
* keiner Zuordnung vorkommt, weil das unsichtbare Zeichen im Namen steckt.
*
* `formelschutz: false` schaltet das vorangestellte Hochkomma ab (siehe
* sanitizeForSpreadsheetCell). Es gehört in eine Datei, die ein Mensch in Excel
* öffnet, und es verdirbt eine, die eine Maschine einliest: eine österreichische
* Telefonnummer beginnt mit „+", und im Zielsystem stünde danach `'+43…` als
* Nummer. Nur für Dateien setzen, die ausschliesslich ein Fremdsystem liest.
*/
export type CsvForm = { trenner?: "," | ";"; bom?: boolean };
export type CsvForm = { trenner?: "," | ";"; bom?: boolean; formelschutz?: boolean };
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[], form: CsvForm = {}): string {
const trenner = form.trenner ?? ";";
const bom = form.bom ?? true;
const lines = [columns.map((c) => csvCell(c.header, trenner)).join(trenner)];
const schutz = form.formelschutz ?? true;
const lines = [columns.map((c) => csvCell(c.header, trenner, schutz)).join(trenner)];
for (const row of rows) {
lines.push(columns.map((c) => csvCell(c.get(row), trenner)).join(trenner));
lines.push(columns.map((c) => csvCell(c.get(row), trenner, schutz)).join(trenner));
}
return (bom ? "" : "") + lines.join("\r\n");
}

55
lib/hay-grade.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { PaygradeType } from "./types";
// Die Hay-Grades — die Bewertungsstufen, die der Kunde tatsächlich führt.
//
// Bis September 2026 stand an dieser Stelle eine Verwendungsgruppe A–F samt
// Beschreibungen („B – Qualifiziert"). Die stammte aus der Spezifikation, nicht
// vom Kunden: sechs erfundene Stufen. Dieselbe Spalte füllt im
// Cornerstone-Extrakt die „Grade ID", und dort ist A–F keine Kennung, die das
// Zielsystem kennt — die Datei wäre in jeder Zeile falsch gewesen, ohne dass
// es beim Erzeugen aufgefallen wäre.
//
// Gespeichert wird die ID, angezeigt der Titel. Bei den HG-Stufen ist beides
// dasselbe; nur „Generic Grade" trägt als ID einen Bindestrich. Der steht so
// in der Tabelle des Kunden und wird hier nicht in eine leere Angabe
// übersetzt: ein Bindestrich ist dort ein Wert, kein fehlender.
//
// Dieselbe Liste steht in Migration 20260928140000 als CHECK der Domain
// paygrade_type; tests/unit/hay-grade.test.ts hält beide gegeneinander.
export const HAY_GRADES: readonly { value: PaygradeType; label: string }[] = [
{ value: "-", label: "Generic Grade" },
{ value: "HG09", label: "HG09" },
{ value: "HG10", label: "HG10" },
{ value: "HG11", label: "HG11" },
{ value: "HG12", label: "HG12" },
{ value: "HG13", label: "HG13" },
{ value: "HG14", label: "HG14" },
{ value: "HG15", label: "HG15" },
{ value: "HG16", label: "HG16" },
{ value: "HG17", label: "HG17" },
{ value: "HG18", label: "HG18" },
{ value: "HG19", label: "HG19" },
{ value: "HG19P", label: "HG19P" },
{ value: "HG20", label: "HG20" },
] as const;
export const HAY_GRADE_WERTE: readonly PaygradeType[] = HAY_GRADES.map((g) => g.value);
/**
* Die Vorgabe — und zugleich der Wert, den der Bestand bei der Umstellung
* bekommen hat.
*
* Aus A–F liesse sich kein Hay-Grade ableiten: die alten Stufen waren eine
* andere Einteilung mit einer anderen Anzahl. Jede Zuordnung wäre geraten,
* und geraten sähe im Extrakt genauso aus wie erhoben.
*/
export const HAY_GRADE_STANDARD: PaygradeType = "-";
/** Der anzuzeigende Titel; unbekannte Werte bleiben, wie sie in der Zeile stehen. */
export function hayGradeLabel(wert: string | null | undefined): string {
return HAY_GRADES.find((g) => g.value === wert)?.label ?? wert ?? "–";
}
export function istHayGrade(wert: string | null | undefined): wert is PaygradeType {
return HAY_GRADE_WERTE.includes(wert as PaygradeType);
}

View File

@@ -1,4 +1,5 @@
import { ABSENCE_TYPES } from "./absence";
import { HAY_GRADE_WERTE } from "./hay-grade";
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
import { MITARBEITERARTEN } from "./mitarbeiterart";
import { EMERGENCY_RELATIONS } from "./types";
@@ -27,6 +28,12 @@ import { EMERGENCY_RELATIONS } from "./types";
// Fehlwert. Das gehört gemeinsam mit der Anzeigeschicht gelöst, nicht
// nebenbei hier.
export const FELD_AUSWAHLEN: Record<string, readonly string[]> = {
// Kein Aufzählungstyp mehr, sondern eine Domain über text — die Liste darf
// hier also stehen. Angezeigt wird der **gespeicherte** Wert und nicht der
// Titel: „-" statt „Generic Grade". In einem Dialog, der eine Zeile der
// Historie richtigstellt, ist genau das die richtige Anzeige, denn dort
// steht hinterher derselbe Wert.
"Hay-Grade": HAY_GRADE_WERTE,
"Notfallkontakt Verhältnis": EMERGENCY_RELATIONS,
"Kündigungsschutz Personenkreis": KUENDIGUNGSSCHUTZ_GRUENDE,
Mitarbeiterart: MITARBEITERARTEN,

View File

@@ -1,6 +1,7 @@
import "server-only";
import { sql, type Tx } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
import { deriveStatusAsOf } from "@/lib/reports";
import { normalizeSvnr } from "@/lib/svnr";
import { sortiereWochentage } from "@/lib/wochentage";
@@ -58,7 +59,7 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
.select(["position_id"])
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)]))
.execute(),
tx.selectFrom("employees").select(["id", "personnel_number", "email", "company_email", "sv_nummer"]).execute(),
tx.selectFrom("employees").select(["id", "personnel_number", "email", "company_email", "cornerstone_id", "sv_nummer"]).execute(),
]);
const besetzt = new Set(besetzungen.map((b) => b.position_id));
@@ -73,6 +74,7 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
// kann sie fehlen.
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
firmenEmails: new Set(personen.filter((e) => e.company_email).map((e) => e.company_email!.toLowerCase())),
cornerstoneIds: new Set(personen.filter((e) => e.cornerstone_id).map((e) => e.cornerstone_id!)),
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
};
}
@@ -211,6 +213,7 @@ export async function laden(
address_country: txt(w.address_country),
email: txt(w.email)!,
company_email: txt(w.company_email),
cornerstone_id: txt(w.cornerstone_id),
phone: txt(w.phone),
title_prefix: liste(w.title_prefix) ?? [],
title_suffix: liste(w.title_suffix) ?? [],
@@ -227,7 +230,7 @@ export async function laden(
work_days: sortiereWochentage(liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
contract_end_date: txt(w.contract_end_date),
paygrade: (txt(w.paygrade) ?? "B") as never,
paygrade: (txt(w.paygrade) ?? HAY_GRADE_STANDARD) as never,
collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never,
worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never,
monthly_salary_gross: zahl(w.monthly_salary_gross),

View File

@@ -1,4 +1,5 @@
import { ABSENCE_TYPES } from "@/lib/absence";
import { HAY_GRADE_WERTE } from "@/lib/hay-grade";
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
// Was in einer Importdatei stehen darf.
@@ -240,6 +241,14 @@ export const BLATT_PERSONEN: BlattSchema = {
hinweis: "Dienstliche Adresse. Freiwillig, eindeutig. Geht in den Export für die Mitarbeiterbefragung.",
beispiel: "s.aigner@manner.com",
},
{
name: "Cornerstone-ID",
ziel: "cornerstone_id",
pflicht: false,
typ: { art: "text" },
hinweis: "Kennung im Lernsystem Cornerstone. Freiwillig, eindeutig. Füllt dort „User ID“ und „Username“.",
beispiel: "10000382",
},
{ name: "Private Telefonnummer", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
@@ -331,12 +340,12 @@ export const BLATT_PERSONEN: BlattSchema = {
beispiel: "",
},
{
name: "Verwendungsgruppe",
name: "Hay-Grade",
ziel: "paygrade",
pflicht: false,
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
hinweis: "Leer = B.",
beispiel: "C",
typ: { art: "auswahl", werte: [...HAY_GRADE_WERTE] },
hinweis: "Leer = Generic Grade, also ein Bindestrich.",
beispiel: "HG13",
},
{
name: "Kollektivvertrag",

View File

@@ -35,6 +35,7 @@ export type Bestand = {
personalnummern: Map<number, string>;
emails: Set<string>;
firmenEmails: Set<string>;
cornerstoneIds: Set<string>;
svNummern: Set<string>;
};
@@ -46,6 +47,7 @@ export const LEERER_BESTAND: Bestand = {
personalnummern: new Map(),
emails: new Set(),
firmenEmails: new Set(),
cornerstoneIds: new Set(),
svNummern: new Set(),
};
@@ -265,6 +267,7 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
const personalnummern = new Set([...bestand.personalnummern.keys()]);
const emails = new Set([...bestand.emails]);
const firmenEmails = new Set([...bestand.firmenEmails]);
const cornerstoneIds = new Set([...bestand.cornerstoneIds]);
const svNummern = new Set([...bestand.svNummern]);
const belegtePlanstellen = new Map<string, number>();
const eintritte = new Map<number, string>();
@@ -297,6 +300,17 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
}
}
// Die Kennung gehört genau einer Person; die Spalte trägt einen
// eindeutigen Index. Ohne Prüfung hier bräche erst das Schreiben ab —
// mitten im Import und ohne Zeilennummer.
const cornerstoneId = s(w.cornerstone_id);
if (cornerstoneId) {
if (cornerstoneIds.has(cornerstoneId)) {
melde("Personen", z.zeile, "Cornerstone-ID", "Kommt bereits vor.", cornerstoneId);
}
cornerstoneIds.add(cornerstoneId);
}
const geburt = s(w.birth_date);
const svRoh = s(w.sv_nummer);
if (svRoh) {

158
lib/lolyo.ts Normal file
View File

@@ -0,0 +1,158 @@
import type { CsvForm, ExportColumn } from "./export";
// Der Export für LOLYO, die Mitarbeiter-App: eine Zeile je Person, die dort
// einen Zugang bekommen soll.
//
// Diese Datei tut etwas anderes als die beiden Exporte daneben. Honestly lädt
// zu einer Befragung ein, Cornerstone führt Stammdaten nach — hier entstehen
// **Konten**, samt Passwort in der Zeile. Wer die Datei hat, hat damit einen
// Zugang zu jedem Konto darin, solange niemand sein Passwort gewechselt hat.
// Deshalb steht das Passwort nicht in dieser Datei: die Route reicht es herein
// (app/api/export/lolyo/route.ts), und es gelangt damit nie in ein Bündel, das
// der Browser lädt. Dieselbe Überlegung steht in lib/passwort.ts.
//
// Kopfzeile und Trennzeichen kommen aus der Vorlage des Anbieters, Zeichen für
// Zeichen. Die gemischte Schreibweise — „Titel prefix" deutsch, „Title Suffix"
// englisch — steht so darin; sie hier stillschweigend zu vereinheitlichen
// hiesse, eine Spalte zu liefern, die LOLYO nicht zuordnet.
/**
* Semikolon, kein BOM, kein Formelschutz — so liest LOLYO die Datei.
*
* Die Vorgabe von toCsv ist für Excel gemacht. Das BOM hinge unsichtbar am
* Namen der ersten Spalte, und „Benutzer/Code" käme beim Einlesen in keiner
* Zuordnung mehr vor.
*
* Der Formelschutz stellt jedem Wert, der mit `=`, `+`, `-` oder `@` beginnt,
* ein Hochkomma voran, damit Excel ihn nicht als Formel rechnet. Hier wäre das
* falsch: **jede** österreichische Telefonnummer beginnt mit „+", und in LOLYO
* stünde danach `'+4366…` als Nummer — in jedem Konto, und niemandem fiele es
* beim Erzeugen auf. Die Datei liest eine Maschine, kein Mensch in Excel.
*/
export const LOLYO_CSV: CsvForm = { trenner: ";", bom: false, formelschutz: false };
/** Für alle dieselbe. Wer eine andere braucht, stellt sie in LOLYO um, nicht hier. */
export const LOLYO_SPRACHE = "DE";
/**
* Die Gruppe, in die alle kommen.
*
* Fest vorgegeben, nicht aus der Organisationseinheit abgeleitet: LOLYO-Gruppen
* sind Verteiler in der App und nicht die Aufbauorganisation. Beides
* gleichzusetzen erzeugte 78 Gruppen, die dort niemand bestellt hat.
*/
export const LOLYO_GRUPPE = "Demo-Gruppe";
/** LOLYO holt die Bestätigung selbst ein; aus Alpenwerk kommt sie nie bestätigt. */
export const LOLYO_EMAIL_BESTAETIGT = "0";
/**
* Die Anrede aus dem Geschlecht.
*
* Alpenwerk kennt genau zwei Werte (gender_type), und die Vorgabe nennt genau
* zwei Anreden. Ein dritter Wert bliebe hier leer statt geraten — eine falsche
* Anrede steht in jeder Nachricht, die die App verschickt.
*/
export function anrede(gender: string): string {
if (gender === "m") return "Herr";
if (gender === "w") return "Frau";
return "";
}
/**
* Typografische Zeichen auf ihre ASCII-Entsprechung.
*
* Im Zielsystem kam der Gedankenstrich „–" als `–` an — das sind seine
* UTF-8-Bytes, gelesen als Windows-1252. Gemessen hat die Vorlage des Anbieters
* dieselbe Kodierung wie unsere Datei (UTF-8 ohne BOM, nachgeprüft am
* „bestätigt" in ihrer Kopfzeile), die Ursache liegt also nicht in dem, was wir
* schreiben. Solange sie nicht geklärt ist, geht der Weg über das Zeichen
* selbst: ein Bindestrich sagt dasselbe und überlebt jede Fehlinterpretation,
* weil er in beiden Kodierungen dasselbe Byte ist.
*
* Umlaute bleiben. „Bäckerei" zu „Baeckerei" zu machen hiesse, ein Problem zu
* verstecken, das es entweder gar nicht gibt — oder das dann auch Namen
* betrifft, und dort wäre es keine Kosmetik mehr.
*/
export function ohneTypografie(text: string): string {
return text
.replace(/[‐-―−]/g, "-")
.replace(/[‘’‚‛′]/g, "'")
.replace(/[“”„‟″]/g, '"')
.replace(/…/g, "...")
.replace(/[   ]/g, " ");
}
/** Die Person, so wie der Export sie braucht. */
export type LolyoQuelle = {
id: string;
company_email: string | null;
title_prefix: string[];
title_suffix: string[];
first_name: string;
last_name: string;
job_title: string;
phone: string | null;
gender: string;
};
export type LolyoZeile = Record<string, string>;
/**
* Eine Zeile.
*
* Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das
* hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene
* Kennung, die wir treffen müssten — die UUID ist der Schlüssel, über den sich
* ein späterer Abgleich wieder auf dieselbe Person bezieht.
*
* Die E-Mail-Spalte trägt die **Firmenadresse** und niemals die private aus
* employees.email: die Datei legt Konten bei einem fremden Anbieter an.
*/
export function baueLolyoZeile(p: LolyoQuelle, passwort: string): LolyoZeile {
// ohneTypografie steht an jeder Zelle, die aus den Daten kommt, und nicht nur
// an der Position: aufgefallen ist es dort, weil die Tätigkeiten einen
// Gedankenstrich führen — dieselben Zeichen stehen aber auch in Namen und
// Titeln, und die fielen erst bei der Person auf, die einen trägt.
const aus = (wert: string) => ohneTypografie(wert);
return {
"Benutzer/Code": p.id,
Email: aus(p.company_email ?? ""),
// **Ohne** Umschrift: das Passwort muss Zeichen für Zeichen das sein, was
// die Person eingibt. Ein ersetztes Zeichen fiele niemandem auf — es gäbe
// nur ein Konto, in das niemand hineinkommt.
Passwort: passwort,
"Titel prefix": aus(p.title_prefix.join(" ")),
Vorname: aus(p.first_name),
Nachname: aus(p.last_name),
"Title Suffix": aus(p.title_suffix.join(" ")),
Position: aus(p.job_title),
Gruppen: LOLYO_GRUPPE,
Telefon: aus(p.phone ?? ""),
Anrede: anrede(p.gender),
Sprache: LOLYO_SPRACHE,
"E-Mail bestätigt": LOLYO_EMAIL_BESTAETIGT,
};
}
/** Die Spalten in der Reihenfolge der Vorlage. */
export const LOLYO_SPALTEN: readonly string[] = [
"Benutzer/Code",
"Email",
"Passwort",
"Titel prefix",
"Vorname",
"Nachname",
"Title Suffix",
"Position",
"Gruppen",
"Telefon",
"Anrede",
"Sprache",
"E-Mail bestätigt",
];
export function lolyoSpalten(): ExportColumn<LolyoZeile>[] {
return LOLYO_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
}

46
lib/org-nummer.ts Normal file
View File

@@ -0,0 +1,46 @@
// Ein Vorschlag für die nächste Orgnummer.
//
// Die Nummer wird eingetragen, nicht erzeugt — wie die Personalnummer, und aus
// demselben Grund: sie stammt aus dem führenden System des Kunden, und eine
// selbst vergebene Nummer wäre eine, die dort niemand kennt. Ein leeres Feld
// in einem Dialog, der sonst zwei Klicks braucht, ist aber die Stelle, an der
// jemand ins Stocken gerät.
//
// Deshalb ein **Vorschlag** und keine Vergabe: erkennbar ist nur, was sich aus
// dem Bestand ablesen lässt, und wenn sich nichts ablesen lässt, steht hier
// nichts. Geraten wird nicht — eine plausibel aussehende, aber erfundene
// Systematik wäre schlimmer als ein leeres Feld, weil sie niemand nachprüft.
//
// Erkannt wird genau ein Muster: gleichbleibender Vorspann, dann Ziffern
// („OE-0013“, „10000001“). Führende Nullen bleiben in der Breite erhalten, die
// der Bestand führt.
const MUSTER = /^(.*?)(\d+)$/;
export function naechsteOrgnummer(vorhandene: readonly string[]): string | undefined {
if (vorhandene.length === 0) return undefined;
let vorspann: string | null = null;
let breite = 0;
let hoechste = 0;
for (const nummer of vorhandene) {
const treffer = MUSTER.exec(nummer.trim());
if (!treffer) return undefined;
const [, kopf, ziffern] = treffer;
// Zwei Systematiken nebeneinander („OE-0013“ und „B7“) — dann gibt es
// keine nächste Nummer, sondern zwei Reihen, und welche gemeint ist,
// weiss nur der Mensch vor dem Dialog.
if (vorspann === null) vorspann = kopf;
else if (vorspann !== kopf) return undefined;
breite = Math.max(breite, ziffern.length);
hoechste = Math.max(hoechste, Number(ziffern));
}
const naechste = hoechste + 1;
// Wächst die Zahl über die bisherige Breite hinaus, wird sie eben länger —
// abzuschneiden hiesse, eine bereits vergebene Nummer vorzuschlagen.
return `${vorspann}${String(naechste).padStart(breite, "0")}`;
}

View File

@@ -60,7 +60,16 @@ export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult>
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
// Nachladen weg: sechs Abfragen, jede vollständig.
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
// Zum Stichtag, wie die Planstellen und die Besetzungen darunter. Hier
// stand die Tabelle ungefiltert: eine Einheit, die es 2020 noch nicht gab,
// erschien in der Organisation von 2020. Gemeldet am 29.09. (D.06).
tx
.selectFrom("org_units")
.select(["id", "parent_id"])
.where("valid_from", "<=", asOf)
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.orderBy("id")
.execute(),
tx
.selectFrom("om_positions as p")

View File

@@ -42,6 +42,20 @@ export type OpenPositionResolved = {
kostenstelle: { code: string; name: string } | null;
};
/**
* Was der Änderungsdialog von einer Planstelle braucht — und nicht mehr.
*
* Er verlangte bisher eine `OpenPositionResolved`, also die *unbesetzte*
* Planstelle samt „seit wann offen" und „wer würde sie führen". Damit liess er
* sich nur dort öffnen, wo diese Angaben entstehen: auf der Seite Positionen,
* die ausschliesslich unbesetzte Stellen zeigt. Die Kontierung einer besetzten
* Planstelle war deshalb nirgends änderbar — bei Manner betraf das 784 von 788.
*/
export type PlanstelleZumAendern = Pick<
OpenPositionResolved,
"id" | "position_number" | "title" | "org_unit_id" | "is_chief" | "valid_from" | "valid_to" | "kostenstelle"
>;
/**
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe

View File

@@ -1,5 +1,7 @@
import { ABSENCE_TYPES } from "./absence";
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNGSART_WERTE } from "./beendigung";
import { fmtDate } from "./format";
import { HAY_GRADE_WERTE } from "./hay-grade";
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
import { MITARBEITERARTEN } from "./mitarbeiterart";
import { parseIsoDateParam } from "./reports";
@@ -49,7 +51,7 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
{ key: "worker", label: "Beschäftigtengruppe", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in", "Lehrling") },
{ key: "mitarbeiterart", label: "Mitarbeiterart", alle: "Alle Mitarbeiterarten", optionen: werte(...MITARBEITERARTEN) },
{ key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") },
{ key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") },
{ key: "paygrade", label: "Hay-Grade", alle: "Alle Hay-Grades", optionen: werte(...HAY_GRADE_WERTE) },
{ key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") },
{
key: "gender",
@@ -178,6 +180,49 @@ export function anzahlKriterien(c: Criteria): number {
return n;
}
/**
* Die gesetzten Kriterien in Worten.
*
* Die Exportkarten nannten nur den Status. Die übrigen Kriterien stehen
* eingeklappt weiter oben und wirken auf jeden Export dieser Seite genauso —
* wer eines gesetzt und vergessen hatte, lud eine Datei herunter, die nach dem
* ganzen Haus aussah und einen Ausschnitt enthielt. Bei einer Datei für ein
* Zielsystem merkt das niemand mehr, sobald sie dort eingespielt ist: dort
* fehlen dann Personen, und es sieht aus wie ein Datenstand, nicht wie ein
* Filter.
*
* `anzahlKriterien` daneben zählt dasselbe; hier steht, *was* es ist.
*/
export function beschreibeKriterien(c: Criteria): string[] {
const teile: string[] = [];
for (const k of AUSWAHL_KRITERIEN) {
const wert = c.auswahl[k.key];
if (!wert) continue;
teile.push(`${k.label}: ${k.optionen.find((o) => o.wert === wert)?.label ?? wert}`);
}
for (const k of JANEIN_KRITERIEN) {
const wert = c.jaNein[k.key];
if (wert) teile.push(`${k.label}: ${wert}`);
}
for (const k of ZEITRAUM_KRITERIEN) {
const z = c.zeitraum[k.key];
if (!z) continue;
if (z.von && z.bis) teile.push(`${k.label} ${fmtDate(z.von)} bis ${fmtDate(z.bis)}`);
else if (z.von) teile.push(`${k.label} ab ${fmtDate(z.von)}`);
else if (z.bis) teile.push(`${k.label} bis ${fmtDate(z.bis)}`);
}
const { stundenVon: von, stundenBis: bis } = c;
if (von !== undefined && bis !== undefined) teile.push(`Wochenstunden ${von} bis ${bis}`);
else if (von !== undefined) teile.push(`Wochenstunden ab ${von}`);
else if (bis !== undefined) teile.push(`Wochenstunden bis ${bis}`);
return teile;
}
/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */
export function kriterienParameter(): string[] {
return [

View File

@@ -1,6 +1,7 @@
import { ABSENCE_TYPES } from "./absence";
import { ABSENCE_TYPES, statusLabel } from "./absence";
import { BEENDIGUNGSART_WERTE } from "./beendigung";
import { fmtName, todayIso, yearsBetweenIso } from "./format";
import { HAY_GRADE_WERTE } from "./hay-grade";
import { WOCHENTAGE } from "./wochentage";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./types";
@@ -31,6 +32,33 @@ export type GroupDimension =
| "teilzeit_art"
| "weekday";
/**
* Die Kennzahl mit dem, was sie tatsächlich zählt.
*
* „Headcount" stand auf der Übersicht für die Aktiven und im Bericht für eine
* andere Zahl — dasselbe Wort für Verschiedenes, gemeldet als N.02. Der Kunde
* führt zwei Grössen mit festen Namen (Max, 30.09.): **Headcount Aktiv** für
* die Aktiven und **Headcount Aktives Dienstverhältnis** für Aktive plus
* Langzeitabwesende.
*
* Die Kennzahl im Bericht zählt aber, was gerade ausgewählt ist — auch
* Geplante oder Ausgetretene. Ein fester Name wäre dort also schlicht falsch.
* Deshalb: trifft die Auswahl eine der beiden Grössen, steht ihr Name da;
* sonst steht dabei, welche Status gezählt wurden.
*/
export function messgroesseLabel(measure: Measure, statuses: readonly string[]): string {
const basis = MEASURE_LABELS[measure];
if (measure !== "headcount") return basis;
const gewaehlt = new Set(statuses);
if (gewaehlt.size === 1 && gewaehlt.has("Aktiv")) return "Headcount Aktiv";
if (gewaehlt.size === 2 && gewaehlt.has("Aktiv") && gewaehlt.has("Karenz")) {
return "Headcount Aktives Dienstverhältnis";
}
if (gewaehlt.size === 0) return basis;
return `${basis} (${statuses.map((s) => statusLabel(s as EmploymentStatus)).join(", ")})`;
}
export const MEASURE_LABELS: Record<Measure, string> = {
headcount: "Headcount",
fte: "FTE",
@@ -51,7 +79,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
contract_type: "Vertragsart",
entry_year: "Eintrittsjahr",
source: "Intern/Extern",
paygrade: "Paygrade",
paygrade: "Hay-Grade",
worker_type: "Beschäftigtengruppe",
collective_agreement: "Kollektivvertrag",
betriebsrat: "Betriebsrat",
@@ -235,20 +263,43 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
}
}
function weekdayRank(key: string): number {
const i = WOCHENTAGE.indexOf(key as Weekday);
return i === -1 ? WOCHENTAGE.length : i;
/**
* Dimensionen, deren Werte eine eigene Reihenfolge haben.
*
* Alles andere wird nach der Kennzahl sortiert, gross zuerst — bei „Bereich"
* oder „Standort" ist das die Antwort auf die Frage, die der Bericht stellt.
* Bei einer Leiter ist es keine: Montag vor Dienstag und HG09 vor HG10 sind
* die Reihenfolge, in der die Werte *sind*, und eine nach Häufigkeit
* umgestellte Leiter liest sich als Zufall.
*
* HG09 ist die unterste Stufe, HG20 die oberste. Der „Generic Grade" (`-`)
* steht vor allen: er ist keine Stufe, sondern ihr Fehlen, und vor der
* niedrigsten ist der Platz, an dem das am wenigsten nach einer Aussage
* aussieht.
*/
const EIGENE_REIHENFOLGE: Partial<Record<GroupDimension, readonly string[]>> = {
weekday: WOCHENTAGE,
paygrade: HAY_GRADE_WERTE,
};
function rang(dim: GroupDimension, key: string): number {
const liste = EIGENE_REIHENFOLGE[dim];
if (!liste) return 0;
const i = liste.indexOf(key);
// Unbekanntes hinten, nicht vorn: ein Wert, den die Liste nicht kennt, soll
// auffallen und nicht die Leiter anführen.
return i === -1 ? liste.length : i;
}
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
function sortiereNachReihenfolge<T extends { key: string }>(items: T[], dim: GroupDimension): T[] {
return [...items].sort((a, b) => rang(dim, a.key) - rang(dim, b.key));
}
// Reused by ReportsPageClient (split legend) and the report export route
// (split columns) to render a `weekday` split chronologically rather than
// (split columns) to render a split in the order its values have rather than
// in first-encountered order; a no-op for every other dimension.
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(b)) : keys;
return EIGENE_REIHENFOLGE[dim] ? [...keys].sort((a, b) => rang(dim, a) - rang(dim, b)) : keys;
}
// Every dimension other than `weekday` is a strict single-key partition
@@ -326,11 +377,11 @@ export function aggregateReport(
value: measureValue(sRows, measure, asOf),
count: sRows.length,
}));
row.split = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
row.split = EIGENE_REIHENFOLGE[split] ? sortiereNachReihenfolge(splitRows, split) : splitRows;
}
rows.push(row);
}
return group === "weekday" ? sortByWeekday(rows) : rows.sort((a, b) => b.value - a.value);
return EIGENE_REIHENFOLGE[group] ? sortiereNachReihenfolge(rows, group) : rows.sort((a, b) => b.value - a.value);
}
export function sumValues(rows: { value: number }[]): number {
@@ -349,7 +400,7 @@ export function totalForRows(rows: { value: number; count: number }[], measure:
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
{ name: "Headcount nach Hay-Grade", measure: "headcount", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" },
{ name: "Headcount nach C-Level", measure: "headcount", group: "c_level" },

View File

@@ -6,7 +6,26 @@
export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
export type EmploymentType = "Vollzeit" | "Teilzeit";
export type ContractType = "unbefristet" | "befristet";
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
/**
* Der Hay-Grade. Der Name der Spalte ist `paygrade` geblieben — sie steht in
* rund zehn SQL-Funktionen, und eine Umbenennung hätte jede davon angefasst,
* ohne dass sich am Inhalt etwas ändert. Die Liste führt lib/hay-grade.ts.
*/
export type PaygradeType =
| "-"
| "HG09"
| "HG10"
| "HG11"
| "HG12"
| "HG13"
| "HG14"
| "HG15"
| "HG16"
| "HG17"
| "HG18"
| "HG19"
| "HG19P"
| "HG20";
export type SourceType = "Intern" | "Extern";
export type GenderType = "m" | "w";
/**
@@ -214,6 +233,14 @@ export type Database = {
* das Haus verlassen. Der Honestly-Export nimmt diese hier.
*/
company_email: string | null;
/**
* Kennung der Person im Lernsystem Cornerstone (CSOD).
*
* Freiwillig und eindeutig. Als Text geführt — führende Nullen
* gingen in einer numerischen Spalte verloren, und gerechnet wird
* mit der Kennung ohnehin nicht.
*/
cornerstone_id: string | null;
phone: string | null;
job_title: string;
location_id: string;
@@ -294,6 +321,7 @@ export type Database = {
address_country?: string | null;
email?: string | null;
company_email?: string | null;
cornerstone_id?: string | null;
phone?: string | null;
job_title: string;
location_id: string;
@@ -733,6 +761,12 @@ export type Database = {
// Kopie einer bestehenden Planstelle — gleiche Einheit, Tätigkeit und
// Kontierung, neue Nummer, unbesetzt. Leitungsplanstellen ausgenommen.
clone_position: { Args: { payload: Record<string, unknown> }; Returns: string };
// Eine Organisationseinheit unter einer bestehenden. Nur anlegen:
// parent_id trägt kein Datum, ein Verschieben änderte deshalb auch die
// Vergangenheit — siehe Migration 20260929120000.
create_org_unit: { Args: { payload: Record<string, unknown> }; Returns: string };
// Nur, wenn nichts darunter hängt und nie etwas hing — siehe 20260929140000.
delete_org_unit: { Args: { payload: Record<string, unknown> }; Returns: void };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };

View File

@@ -55,6 +55,10 @@ export function vorbelegungAus(employee: EmployeeRow): Partial<HireDraftData> {
// stillschweigend. Sichtbar vorbelegt lässt sie sich bewusst ersetzen
// oder leeren.
companyEmail: employee.company_email ?? "",
// Aus demselben Grund vorbelegt wie die Firmenadresse: der Assistent
// schickt das Feld in jedem Fall mit, und leer geliefert löschte es die
// gespeicherte Kennung stillschweigend.
cornerstoneId: employee.cornerstone_id ?? "",
phone: employee.phone ?? "",
locationId: employee.location_id,
besetzung: employee.source,

View File

@@ -0,0 +1,86 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DateField, istMeldbaresDatum } from "@/components/ui/Field";
// Der Anlass: in den Berichten hängt jedes Datumsfeld an der Adresszeile, eine
// Änderung heisst also neu laden. Ein `<input type="date">` meldet beim Tippen
// der Jahreszahl aber viermal — 0002, 0020, 0202, 2026 —, und die ersten drei
// setzten das Feld mitten im Tippen zurück. Mit dem Kalender ging es, weil der
// in einem Zug ein fertiges Datum setzt.
describe("istMeldbaresDatum", () => {
it("nimmt ein vollständiges Datum an", () => {
expect(istMeldbaresDatum("2026-09-30")).toBe(true);
expect(istMeldbaresDatum("1980-01-01")).toBe(true);
});
it("nimmt das leere Feld an, denn kein Datum ist auch eine Aussage", () => {
expect(istMeldbaresDatum("")).toBe(true);
});
it("weist die Zwischenstände beim Tippen ab", () => {
for (const zwischen of ["0002-09-30", "0020-09-30", "0202-09-30"]) {
expect(istMeldbaresDatum(zwischen), zwischen).toBe(false);
}
});
it("weist Bruchstücke ab", () => {
expect(istMeldbaresDatum("2026-09")).toBe(false);
expect(istMeldbaresDatum("2026")).toBe(false);
});
});
describe("DateField", () => {
function feld(onChange: (v: string) => void, value = "") {
render(<DateField label="Von" value={value} onChange={onChange} />);
return screen.getByLabelText("Von") as HTMLInputElement;
}
it("meldet den Zwischenstand nicht, zeigt ihn aber an", () => {
const gemeldet = vi.fn();
const input = feld(gemeldet);
fireEvent.change(input, { target: { value: "0002-09-30" } });
expect(gemeldet).not.toHaveBeenCalled();
// Der Tippstand bleibt stehen: würde das Feld ihn verwerfen, kämpfte es
// gegen die Eingabe.
expect(input.value).toBe("0002-09-30");
});
it("meldet, sobald die Jahreszahl vollständig ist", () => {
const gemeldet = vi.fn();
const input = feld(gemeldet);
fireEvent.change(input, { target: { value: "0002-09-30" } });
fireEvent.change(input, { target: { value: "2026-09-30" } });
expect(gemeldet).toHaveBeenCalledExactlyOnceWith("2026-09-30");
});
it("meldet das Leeren", () => {
const gemeldet = vi.fn();
const input = feld(gemeldet, "2026-09-30");
fireEvent.change(input, { target: { value: "" } });
expect(gemeldet).toHaveBeenCalledExactlyOnceWith("");
});
it("reicht beim Verlassen nach, was liegengeblieben ist", () => {
// Kommt in der Praxis kaum vor, ist aber der Rückfall: was nicht gemeldet
// wurde, geht spätestens hier hinaus — sofern es eine Aussage ist.
const gemeldet = vi.fn();
const input = feld(gemeldet);
fireEvent.change(input, { target: { value: "0002-09-30" } });
fireEvent.blur(input);
expect(gemeldet).not.toHaveBeenCalled();
});
it("übernimmt einen Wert, der von aussen kommt", () => {
// „Zurücksetzen" in den Kriterien leert das Feld — der Tippstand darf
// dann nicht stehenbleiben.
const { rerender } = render(<DateField label="Von" value="2026-09-30" onChange={vi.fn()} />);
const input = screen.getByLabelText("Von") as HTMLInputElement;
expect(input.value).toBe("2026-09-30");
rerender(<DateField label="Von" value="" onChange={vi.fn()} />);
expect(input.value).toBe("");
});
});

View File

@@ -0,0 +1,116 @@
import { render as rtlRender, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { buildUnitTree, PositionTree } from "@/components/orgchart/PositionTree";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
import { ToastProvider } from "@/components/ui/Toast";
vi.mock("@/actions/org", () => ({
createOrgUnit: vi.fn(async () => ({ success: true })),
deleteOrgUnit: vi.fn(async () => ({ success: true })),
}));
// Die Angaben zu einer Einheit laden ihre Planstellen nach; das Aktionsmodul
// zieht über lib/db das Paket `server-only` nach und ist im Test ein
// Importfehler. Geprüft wird hier, was die Ansicht anbietet, nicht was die
// Datenbank liefert.
vi.mock("@/actions/positions", () => ({
ladePlanstellenDerEinheit: vi.fn(async () => ({ planstellen: [], kostenstellen: [], heute: "2026-09-29" })),
updatePosition: vi.fn(async () => ({ success: true })),
setPositionCostCenter: vi.fn(async () => ({ success: true })),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
function render(ui: React.ReactElement) {
return rtlRender(<ToastProvider>{ui}</ToastProvider>);
}
// Der Knopf „Einheit darunter anlegen" hängt allein daran, dass ein Knoten
// eine `unitId` trägt. Verschwindet die still — etwa weil jemand den Baum
// umbaut —, bleibt die Oberfläche fehlerfrei und die Handlung ist weg.
const UNITS: OrgUnitNode[] = [
{ id: "u1", org_number: "OE-0001", name: "Gesellschaft", parent_id: null, unit_type: "Gesellschaft" },
{ id: "u2", org_number: "OE-0010", name: "Produktion", parent_id: "u1", unit_type: "Bereich" },
];
const EMPLOYEES: OrgEmployee[] = [
{
id: "p1",
personnel_number: 1,
first_name: "Anna",
last_name: "Huber",
job_title: "Maschinenbedienung",
manager_id: null,
formal_manager_id: null,
absent: false,
absence_type: null,
org_unit_id: "u2",
is_chief: false,
position_id: "s1",
position_number: "S-0001",
},
];
const VACANCIES: OrgVacancy[] = [
{ position_id: "s2", position_number: "S-0002", job_title: "Schlosser:in", org_unit_id: "u2", is_chief: false },
];
describe("buildUnitTree", () => {
const baum = buildUnitTree(UNITS, EMPLOYEES, VACANCIES);
it("trägt die Einheit an jedem Einheitenknoten", () => {
expect(baum[0].unitId).toBe("u1");
expect(baum[0].children[0].unitId).toBe("u2");
});
it("trägt sie an nichts anderem", () => {
// Eine Person, eine Tätigkeitsgruppe oder eine Vakanz ist keine Einheit —
// ein Plus daran würde etwas anlegen, das woanders hinge.
const bereich = baum[0].children[0];
for (const kind of bereich.children.filter((k) => k.kind !== "role")) {
expect(kind.unitId, `„${kind.label}" darf keine Einheit tragen`).toBeUndefined();
for (const enkel of kind.children) expect(enkel.unitId).toBeUndefined();
}
});
});
describe("PositionTree", () => {
it("bietet je Einheit einen Knopf zum Anlegen an", async () => {
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
// Die Wurzel ist aufgeklappt; ihr Kind ist damit sichtbar.
expect(await screen.findByRole("button", { name: /Einheit unter .*Gesellschaft anlegen/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Einheit unter .*Produktion anlegen/ })).toBeInTheDocument();
});
it("bietet ihn nicht an einer Person an", () => {
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
expect(screen.queryByRole("button", { name: /Einheit unter Huber/ })).not.toBeInTheDocument();
});
it("bietet den Papierkorb nur an, wo nichts darunter hängt", () => {
// „Produktion" trägt eine Person und eine Vakanz, die Gesellschaft trägt
// „Produktion" — entfernbar ist keine von beiden. Ein Knopf, der beim
// Klick eine Absage erteilt, ist schlechter als kein Knopf.
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
expect(screen.queryByRole("button", { name: /Einheit .* entfernen/ })).not.toBeInTheDocument();
});
it("bietet ihn an einer leeren Einheit an", () => {
const leer: OrgUnitNode = { id: "u3", org_number: "OE-0411", name: "Neu", parent_id: "u1", unit_type: "Bereich" };
render(<PositionTree employees={EMPLOYEES} units={[...UNITS, leer]} vacancies={VACANCIES} />);
expect(screen.getByRole("button", { name: /Einheit .*OE-0411.* entfernen/ })).toBeInTheDocument();
});
it("öffnet beim Klick auf eine Einheit deren Angaben", async () => {
// Der Klick auf die Karte war lange ohne Wirkung — die naheliegendste
// Geste in dieser Ansicht, und sie antwortete nicht.
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
await userEvent.click(screen.getByRole("button", { name: /Angaben zu .*Produktion/ }));
const panel = await screen.findByRole("dialog");
expect(within(panel).getByText("OE-0010 · Bereich")).toBeInTheDocument();
// Eine Person, keine Leitung, eine offene Planstelle — genau der Bestand
// aus den Fixtures.
expect(within(panel).getByText("vakant")).toBeInTheDocument();
});
});

View File

@@ -103,6 +103,34 @@ describe("PrintChart — Messung", () => {
});
});
// Von Max gewünscht (30.09.), ausdrücklich zum An- und Abwählen. Die Nummer
// steht hinter dem Namen und nicht in einer eigenen Zeile: auf einem Blatt mit
// zweihundert Kästen kostet jede Zeile Massstab.
describe("PrintChart — Personalnummer", () => {
/** Die Zeile, in der eine Person steht — nicht die Kopfzeile ihrer Einheit. */
function personZeile(nachname: string): string {
return screen.getAllByText(new RegExp(nachname))[0].closest("p")!.textContent ?? "";
}
it("druckt sie erst, wenn sie angehakt ist", async () => {
const user = userEvent.setup();
renderChart();
await zurVorschau(user);
// Wiesinger führt die Gesellschaft und steht damit auf jedem Blatt.
expect(personZeile("Wiesinger")).not.toContain("· 1");
});
it("stellt sie hinter den Namen, sobald sie angehakt ist", async () => {
const user = userEvent.setup();
renderChart();
await user.click(screen.getByLabelText(/Personalnummer/));
await zurVorschau(user);
// Die Leitung steht auch auf Blättern ohne Namensliste — deshalb ist das
// Häkchen auf jeder Tiefe zu haben.
expect(personZeile("Wiesinger")).toContain("· 1");
});
});
describe("PrintChart", () => {
it("fragt zuerst, was gedruckt wird, und zeigt vorher kein Blatt", () => {
renderChart();

View File

@@ -43,7 +43,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
monthly_salary_gross: null,
contract_type: "unbefristet",
contract_end_date: null,
paygrade: "C",
paygrade: "HG13",
source: "Extern",
status: "Geplant",
entry_date: EINTRITT,

View File

@@ -36,7 +36,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
monthly_salary_gross: null,
contract_type: "unbefristet",
contract_end_date: null,
paygrade: "C",
paygrade: "HG13",
source: "Extern",
status: "Aktiv",
entry_date: "2017-07-15",

View File

@@ -92,11 +92,30 @@ describe("Gründe für weniger Stunden", () => {
expect([...STUNDEN_GRUENDE]).toContain("Pflegeteilzeit");
});
it("verteilt die vier Teilzeiten vollständig und ohne Überschneidung", () => {
// Keine darf verlorengehen, und keine an beiden Stellen auftauchen —
// sonst hinge dieselbe Sache an zwei Vorgängen.
const verteilt = [...RUECKKEHR_GRUENDE, ...STUNDEN_GRUENDE].filter((g) => TEILZEITEN.includes(g));
expect(new Set(verteilt)).toEqual(new Set(TEILZEITEN));
expect(verteilt.length).toBe(TEILZEITEN.length);
it("bietet alle vier Teilzeiten bei der Stundenänderung an", () => {
// Bis zum 29.09. standen Eltern- und Wiedereingliederungsteilzeit nur bei
// der Rückkehr — mit der Begründung, dass sie typischerweise dann
// beginnen. Typischerweise ist nicht immer, und wer sie ohne
// vorangegangene Abwesenheit vereinbart, fand sie nirgends. Gemeldet von
// Lara: „Teilzeitvariante Elternteilzeit fehlt".
for (const teilzeit of TEILZEITEN) {
expect([...STUNDEN_GRUENDE], `„${teilzeit}" fehlt bei der Stundenänderung`).toContain(teilzeit);
}
});
it("überschneidet sich dabei bewusst mit der Rückkehr", () => {
// Die Überschneidung ist kein Versehen: es ist derselbe Wert in derselben
// Spalte, nur aus zwei Anlässen erfasst. Was es nicht geben darf, ist eine
// Teilzeit, die an *keinem* der beiden Wege hängt.
const beide = TEILZEITEN.filter(
(t) => ([...RUECKKEHR_GRUENDE] as string[]).includes(t) && ([...STUNDEN_GRUENDE] as string[]).includes(t)
);
expect(new Set(beide)).toEqual(new Set(RUECKKEHR_GRUENDE));
});
it("führt den Normalfall nicht als Teilzeit", () => {
// „Vertragliche Stundenänderung" ist keine Variante, sondern ihr Fehlen:
// sie landet als null in teilzeit_art.
expect([...TEILZEITEN]).not.toContain("Vertragliche Stundenänderung");
});
});

View File

@@ -30,14 +30,24 @@ import { toCsv } from "@/lib/export";
const VORGABE =
"User ID,Local System ID,Prefix,First Name,Middle Name,Last Name,Suffix,Username,Approver,Manager,Absent,Allow Reconciliation,Email,Required Training Approvals,Gender,Exempt,Ethnicity,Months of Service,Compensation Currency,Address Line 1,Address Line 2,City,State,Zip,Mailstop,Country,Original Hire Date,Last Hire Date,Phone,Mobile,Home Phone,Fax,Status,User activation date,User de-activation date,Personal Email,Language,Time Zone,User Type,Employment Status,Leave Reason,Termination Type,Termination Reason,Termination Date,Eligible For Rehire,Rehired Employee,Division ID,Position ID,Grade ID,Cost Center ID,Location ID,Retention Rules,Organisationsstufe,Customfield ID AD,Customfield ID Doxis,Customfield ID Interflex,Customfield ID LGVplus";
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
// Die Kennung, unter der Cornerstone die Person kennt — seit 20260928100000
// fuehrt Alpenwerk sie mit, und sie steht in User ID und Username.
const CS_ID = "10000382";
const kontext: CornerstoneKontext = {
managerNummer: new Map([["chef", 1000]]),
managerKennung: new Map([
["chef", "10000117"],
// Eine vorgesetzte Person, die selbst keine Cornerstone-ID trägt — bei 435
// von 785 Personen der Normalfall, nicht der Sonderfall.
["chef-ohne-kennung", null],
]),
kostenstelle: new Map([["pos-1", { id: "k", code: "41130", name: "Waffelproduktion" }]]),
};
function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
return {
id: "e1",
id: UUID,
personnel_number: 9002,
first_name: "Max",
last_name: "Alpentest",
@@ -45,6 +55,7 @@ function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
title_suffix: [],
gender: "m",
company_email: "m.test@alpenwerk.test.com",
cornerstone_id: CS_ID,
address: "Teststrasse 2",
postal_code: "1010",
city: "Wien",
@@ -140,7 +151,7 @@ describe("Eine Zeile gegen das Muster vom 27.09.2026", () => {
"Local System ID": "9002",
"First Name": "Max",
"Last Name": "Alpentest",
Username: "max.alpentest",
Username: CS_ID,
Email: "m.test@alpenwerk.test.com",
Gender: "male",
"Compensation Currency": "EUR",
@@ -235,13 +246,63 @@ describe("Abhängige Felder", () => {
});
});
describe("Benutzername und Benutzer-ID", () => {
it("tragen denselben Wert", () => {
// In Alpenwerk sind die beiden stets im Gleichklang. Liefen sie
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
// jeder Verweis darauf (Manager, Berichte) träfe womöglich die falsche.
const z = zeile();
expect(z.Username).toBe(z["User ID"]);
});
it("sind die Cornerstone-ID, nicht die UUID und nicht die Personalnummer", () => {
// Hier stand die Alpenwerk-UUID, solange Alpenwerk die Kennung des
// Lernsystems nicht kannte. Sie sagte dort niemandem etwas.
const z = zeile();
expect(z["User ID"]).toBe(CS_ID);
expect(z.Username).toBe(CS_ID);
expect(z["User ID"]).not.toBe(UUID);
expect(z["User ID"]).not.toBe("9002");
});
it("bleiben leer, wenn keine Cornerstone-ID hinterlegt ist", () => {
// Kein Rueckfall auf die UUID: das braechte zwei Kennungsarten in eine
// Datei, ohne dass es auffiele, und legte in Cornerstone eine zweite
// Person neben der bestehenden an. Eine fehlende Angabe soll fehlen.
const z = baueCornerstoneZeile(person({ cornerstone_id: null }), kontext);
expect(z["User ID"]).toBe("");
expect(z.Username).toBe("");
expect(JSON.stringify(z)).not.toContain(UUID);
});
it("fuehren die LOGA-Personalnummer in Local System ID", () => {
expect(zeile()["Local System ID"]).toBe("9002");
});
it("lassen den Anmeldenamen im Customfield AD stehen", () => {
// Der Verzeichnisdienst kennt die Person unter vorname.nachname; das ist
// nicht der Username in Cornerstone, sondern eine eigene Angabe.
const z = zeile();
expect(z["Customfield ID AD"]).toBe("max.alpentest");
expect(z["Customfield ID AD"]).not.toBe(z.Username);
});
});
describe("Kennungen statt Namen", () => {
it("verweist auf die vorgesetzte Person über deren Personalnummer", () => {
// Cornerstone verknüpft über die User ID, nicht über den Namen.
expect(zeile({ manager_id: "chef" }).Manager).toBe("1000");
it("verweist auf die vorgesetzte Person über deren Cornerstone-ID", () => {
// Cornerstone verknüpft über die User ID, nicht über den Namen und nicht
// über die Personalnummer: die ist in dieser Datei die Local System ID.
expect(zeile({ manager_id: "chef" }).Manager).toBe("10000117");
expect(zeile({ manager_id: null }).Manager).toBe("");
});
it("lässt den Verweis leer, wenn die vorgesetzte Person keine Kennung trägt", () => {
// Lieber keine Angabe als eine Kennung der falschen Art: eine
// Personalnummer im Feld Manager sähe aus wie eine User ID und träfe im
// Zielsystem womöglich eine andere Person.
expect(zeile({ manager_id: "chef-ohne-kennung" }).Manager).toBe("");
});
it("nimmt als Kostenstelle den Code der Planstelle", () => {
expect(zeile()["Cost Center ID"]).toBe("41130");
expect(zeile({ position_id: null })["Cost Center ID"]).toBe("");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
import { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search";
// Der Anlass: „Winkler H" gab alle sieben Winkler zurück statt der einen
// Hannah. Das „H" wurde als Teilzeichenkette gesucht und traf damit T-h-omas,
@@ -35,6 +35,38 @@ describe("suchMuster", () => {
]);
});
it("zerlegt an denselben Zeichen wie das Feld, gegen das verglichen wird", () => {
// Das Feld wird per translate an TRENNZEICHEN in Leerzeichen zerlegt. Tat
// es nur das Feld, erzeugte „Müller-Weiß" das Muster „müller-weiß%",
// während im Heuhaufen „müller weiß" stand: null Treffer, obwohl die
// Person da ist. Ohne Bindestrich fand man sie. (C.08, 29.09.)
expect(suchMuster("müller-weiß")).toEqual([
["müller%", "% müller%"],
["weiß%", "% weiß%"],
]);
});
it("zerlegt an jedem der Trennzeichen", () => {
for (const zeichen of [...TRENNZEICHEN]) {
expect(suchMuster(`abc${zeichen}def`), `„${zeichen}" trennt nicht`).toEqual([
["abc%", "% abc%"],
["def%", "% def%"],
]);
}
});
it("findet dieselbe Person mit und ohne Bindestrich", () => {
expect(suchMuster("müller-weiß")).toEqual(suchMuster("müller weiß"));
});
it("hält Trennzeichen und Ersatz gleich lang", () => {
// translate() ordnet Zeichen für Zeichen zu. Wäre der Ersatz kürzer,
// fielen die überzähligen Trennzeichen ersatzlos weg und „Müller-Weiß"
// würde im Feld zu „müllerweiß" — die Suche fände es dann wieder nicht.
expect(TRENNZEICHEN_ERSATZ).toHaveLength(TRENNZEICHEN.length);
expect(TRENNZEICHEN_ERSATZ.trim()).toBe("");
});
it("kennt keinen Platzhalter mitten im Wort", () => {
// Das ist der Kern: „h%" trifft Hannah, „%h%" träfe auch Thomas.
const [, [amAnfang]] = suchMuster("winkler h");

View File

@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { HAY_GRADES, HAY_GRADE_STANDARD, HAY_GRADE_WERTE, hayGradeLabel, istHayGrade } from "@/lib/hay-grade";
// Dieselbe Vorsichtsmassnahme wie bei Personenkreis und Mitarbeiterart: die
// Liste steht in der Migration als CHECK und hier für die Oberfläche. Laufen
// die beiden auseinander, bietet das Formular einen Wert an, den die Datenbank
// abweist — und die Benutzerin sieht einen Fehler aus dem Maschinenraum.
const MIGRATION = join(process.cwd(), "db/migrations/20260928140000_hay_grade.sql");
function werteAusDerBedingung(): string[] {
const sql = readFileSync(MIGRATION, "utf8");
const block = sql.match(/constraint chk_hay_grade check \([\s\S]*?\);/)?.[0];
expect(block, "Die Bedingung steht nicht mehr in der Migration").toBeDefined();
return [...block!.matchAll(/'([^']+)'/g)].map((m) => m[1]);
}
describe("Die Hay-Grades", () => {
it("stimmen mit der Bedingung in der Datenbank überein", () => {
expect(new Set(werteAusDerBedingung())).toEqual(new Set(HAY_GRADE_WERTE));
});
it("führen den Generic Grade als Vorgabe", () => {
// Die Spalte ist NOT NULL mit genau diesem Vorgabewert, und der Bestand
// hat ihn bei der Umstellung bekommen. Weicht die Oberfläche davon ab,
// bekämen neue Zeilen etwas anderes als die vorhandenen.
expect(HAY_GRADE_WERTE).toContain(HAY_GRADE_STANDARD);
expect(readFileSync(MIGRATION, "utf8")).toContain("set default '-'");
});
it("zeigen den Titel und speichern die Kennung", () => {
// Nur beim Generic Grade gehen beide auseinander — genau deshalb darf die
// Anzeige nicht einfach der gespeicherte Wert sein.
expect(hayGradeLabel("-")).toBe("Generic Grade");
expect(hayGradeLabel("HG15")).toBe("HG15");
for (const g of HAY_GRADES) {
if (g.value !== "-") expect(g.label).toBe(g.value);
}
});
it("lassen einen unbekannten Wert stehen, statt ihn zu verschlucken", () => {
// Eine Zeile aus der Zeit vor der Umstellung soll sichtbar falsch sein,
// nicht unsichtbar leer.
expect(hayGradeLabel("B")).toBe("B");
expect(hayGradeLabel(null)).toBe("–");
});
it("weisen zurück, was nicht in der Liste steht", () => {
expect(istHayGrade("HG19P")).toBe(true);
expect(istHayGrade("HG21")).toBe(false);
expect(istHayGrade("B")).toBe(false);
expect(istHayGrade(null)).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder";
@@ -9,15 +9,24 @@ import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder";
// landeten wieder in der Spalte. Ein stiller Rückfall auf den Zustand, den
// diese Datei beheben soll.
//
// Geprüft wird gegen die zuletzt gültige Fassung der Feldkarte.
const MIGRATION = join(process.cwd(), "db/migrations/20260923120000_firmenemail.sql");
// Gelesen werden **alle** Migrationen, nicht eine bestimmte. Hier stand
// einmal ein fester Dateiname mit dem Vermerk „die zuletzt gültige Fassung";
// der war schon zwei Migrationen später falsch, und ein Teil der Feldkarte
// kommt inzwischen ohnehin aus einer Punktänderung an der laufenden
// Definition statt aus einer vollständigen Fassung. Eine Bezeichnung, die
// irgendwann einmal eingetragen wurde, zählt damit als bekannt — das ist
// schwächer als der Stand von heute, aber es ist die Frage, die hier zählt:
// ob jemand einen Schlüssel erfunden hat, den es nie gab.
const MIGRATIONEN = join(process.cwd(), "db/migrations");
function bezeichnungenDerFeldkarte(): string[] {
const sql = readFileSync(MIGRATION, "utf8");
const block = sql.match(/FUNCTION public\.app_feld_karte\(\)[\s\S]*?\$function\$;/)?.[0];
expect(block, "app_feld_karte steht nicht mehr in dieser Migration").toBeDefined();
// Je Zeile: 'Bezeichnung', jsonb_build_array('spalte', 'typ', 'gruppe'),
return [...block!.matchAll(/^\s*'([^']+)',\s+jsonb_build_array\(/gm)].map((m) => m[1]);
const muster = /'([^']+)',\s+jsonb_build_array\('[a-z_]+', '[a-z_]+'/g;
const gefunden = readdirSync(MIGRATIONEN)
.filter((f) => f.endsWith(".sql"))
.flatMap((f) => [...readFileSync(join(MIGRATIONEN, f), "utf8").matchAll(muster)].map((m) => m[1]));
expect(gefunden.length, "Die Feldkarte steht in keiner Migration mehr").toBeGreaterThan(0);
return gefunden;
}
describe("Die Auswahllisten im Dialog „Eintrag berichtigen“", () => {

170
tests/unit/lolyo.test.ts Normal file
View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { toCsv } from "@/lib/export";
import {
anrede,
baueLolyoZeile,
LOLYO_CSV,
LOLYO_EMAIL_BESTAETIGT,
LOLYO_GRUPPE,
LOLYO_SPALTEN,
LOLYO_SPRACHE,
lolyoSpalten,
ohneTypografie,
type LolyoQuelle,
} from "@/lib/lolyo";
// Die Datei legt in LOLYO Konten an. Eine falsche Spalte fällt hier nicht auf,
// sondern dort — als Konto, das niemandem gehört, oder als Zeile, die der
// Import abweist.
// Die Kopfzeile aus der Vorlage des Anbieters, Zeichen für Zeichen.
const VORGABE = "Benutzer/Code;Email;Passwort;Titel prefix;Vorname;Nachname;Title Suffix;Position;Gruppen;Telefon;Anrede;Sprache;E-Mail bestätigt";
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
const PASSWORT = "Startpasswort1!";
function person(teil: Partial<LolyoQuelle> = {}): LolyoQuelle {
return {
id: UUID,
company_email: "max.alpentest@alpenwerk-firmentest.com",
title_prefix: ["Mag."],
title_suffix: ["MBA"],
first_name: "Max",
last_name: "Alpentest",
job_title: "HR-Manager",
phone: "+4366412345678",
gender: "m",
...teil,
};
}
const zeile = (teil: Partial<LolyoQuelle> = {}) => baueLolyoZeile(person(teil), PASSWORT);
describe("Spalten", () => {
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
expect(LOLYO_SPALTEN.join(";")).toBe(VORGABE);
});
it("sind 13", () => {
expect(LOLYO_SPALTEN).toHaveLength(13);
});
it("werden alle gefüllt — keine Spalte ohne Zuordnung", () => {
const z = zeile();
for (const spalte of LOLYO_SPALTEN) {
expect(z, `Spalte "${spalte}" fehlt in der Zeile`).toHaveProperty(spalte);
}
expect(Object.keys(z).sort()).toEqual([...LOLYO_SPALTEN].sort());
});
});
describe("Die Zuordnung", () => {
it("nimmt als Benutzercode die Alpenwerk-UUID", () => {
expect(zeile()["Benutzer/Code"]).toBe(UUID);
});
it("nimmt die Firmenadresse und nie die private", () => {
// Die Datei legt Konten bei einem fremden Anbieter an. Eine Privatadresse
// hat dort nichts verloren; ohne Firmenadresse bleibt die Zelle leer.
expect(zeile().Email).toBe("max.alpentest@alpenwerk-firmentest.com");
expect(zeile({ company_email: null }).Email).toBe("");
});
it("trägt das Passwort ein, das die Route hereinreicht", () => {
expect(zeile().Passwort).toBe(PASSWORT);
});
it("setzt Titel, Name und Position aus Alpenwerk", () => {
const z = zeile();
expect(z["Titel prefix"]).toBe("Mag.");
expect(z.Vorname).toBe("Max");
expect(z.Nachname).toBe("Alpentest");
expect(z["Title Suffix"]).toBe("MBA");
expect(z.Position).toBe("HR-Manager");
});
it("fügt mehrere Titel zusammen und lässt keine zu einer leeren Zelle werden", () => {
expect(zeile({ title_prefix: ["Dr.", "Mag."] })["Titel prefix"]).toBe("Dr. Mag.");
expect(zeile({ title_prefix: [], title_suffix: [] })["Titel prefix"]).toBe("");
});
it("setzt Gruppe, Sprache und Bestätigung fest", () => {
const z = zeile();
expect(z.Gruppen).toBe(LOLYO_GRUPPE);
expect(z.Sprache).toBe(LOLYO_SPRACHE);
expect(z["E-Mail bestätigt"]).toBe(LOLYO_EMAIL_BESTAETIGT);
});
it("leitet die Anrede aus dem Geschlecht ab", () => {
expect(anrede("m")).toBe("Herr");
expect(anrede("w")).toBe("Frau");
// Lieber leer als geraten: eine falsche Anrede steht in jeder Nachricht,
// die die App verschickt.
expect(anrede("")).toBe("");
});
it("lässt eine fehlende Telefonnummer leer", () => {
expect(zeile({ phone: null }).Telefon).toBe("");
});
});
describe("Typografische Zeichen", () => {
it("weichen dem Bindestrich", () => {
// Der Gedankenstrich kam im Zielsystem als „–" an. Ein Bindestrich sagt
// dasselbe und ist in beiden Kodierungen dasselbe Byte.
expect(ohneTypografie("Angestellte/r – F&E")).toBe("Angestellte/r - F&E");
expect(ohneTypografie("Lehrling — Wolkersdorf")).toBe("Lehrling - Wolkersdorf");
});
it("gelten für Anführungszeichen, Auslassungspunkte und geschützte Leerzeichen", () => {
expect(ohneTypografie("„Schnitte“")).toBe('"Schnitte"');
expect(ohneTypografie("O’Brien")).toBe("O'Brien");
expect(ohneTypografie("und so weiter …")).toBe("und so weiter ...");
expect(ohneTypografie("W 17")).toBe("W 17");
});
it("lassen Umlaute in Ruhe", () => {
// „Bäckerei" zu „Baeckerei" zu machen hiesse, ein Problem zu verstecken,
// das dann auch Namen beträfe — und dort wäre es keine Kosmetik mehr.
expect(ohneTypografie("Arbeiter/in – Bäckerei")).toBe("Arbeiter/in - Bäckerei");
expect(ohneTypografie("Müller-Weiß")).toBe("Müller-Weiß");
});
it("gelten für jede Zelle aus den Daten, nicht nur für die Position", () => {
const z = zeile({ job_title: "Angestellte/r – F&E", last_name: "O’Brien", title_suffix: ["M–A"] });
expect(z.Position).toBe("Angestellte/r - F&E");
expect(z.Nachname).toBe("O'Brien");
expect(z["Title Suffix"]).toBe("M-A");
});
it("gelten nicht für das Passwort", () => {
// Ein ersetztes Zeichen fiele niemandem auf — es gäbe nur ein Konto, in
// das niemand hineinkommt.
expect(baueLolyoZeile(person(), "Pass–wort–2026").Passwort).toBe("Pass–wort–2026");
});
});
describe("Die Datei", () => {
it("ist semikolongetrennt und ohne BOM", () => {
// Das BOM hinge unsichtbar am Namen der ersten Spalte, und
// „Benutzer/Code" käme beim Einlesen in keiner Zuordnung mehr vor.
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
expect(csv.startsWith("Benutzer/Code;")).toBe(true);
expect(csv.charCodeAt(0)).not.toBe(0xfeff);
});
it("beginnt mit der Kopfzeile der Vorlage", () => {
const csv = toCsv([], lolyoSpalten(), LOLYO_CSV);
expect(csv.split("\r\n")[0]).toBe(VORGABE);
});
it("lässt die Telefonnummer unangetastet", () => {
// Der Formelschutz von toCsv stellt jedem Wert mit führendem „+" ein
// Hochkomma voran, damit Excel ihn nicht rechnet. Diese Datei liest eine
// Maschine: dort stünde danach in **jedem** Konto `'+4366…` als Nummer,
// weil jede österreichische Nummer mit „+" beginnt.
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
expect(csv).toContain(";+4366412345678;");
expect(csv).not.toContain("'+43");
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { naechsteOrgnummer } from "@/lib/org-nummer";
// Der Vorschlag darf lieber schweigen als raten: eine erfundene, aber
// plausibel aussehende Nummer prüft niemand nach, und sie landet im führenden
// System des Kunden als Kennung, die es dort nicht gibt.
describe("naechsteOrgnummer", () => {
it("zählt die höchste Nummer hoch und behält die Breite", () => {
expect(naechsteOrgnummer(["OE-0001", "OE-0013", "OE-0080"])).toBe("OE-0081");
});
it("richtet sich nach der höchsten, nicht nach der letzten in der Liste", () => {
expect(naechsteOrgnummer(["OE-0080", "OE-0001", "OE-0013"])).toBe("OE-0081");
});
it("kommt auch ohne Vorspann aus", () => {
expect(naechsteOrgnummer(["10000001", "10000002"])).toBe("10000003");
});
it("verlängert, statt eine vergebene Nummer vorzuschlagen", () => {
// 99 + 1 passt nicht mehr in zwei Stellen. Abgeschnitten käme „00“
// heraus, und das gibt es bereits.
expect(naechsteOrgnummer(["OE-98", "OE-99"])).toBe("OE-100");
});
it("schweigt bei zwei Systematiken nebeneinander", () => {
expect(naechsteOrgnummer(["OE-0001", "B7"])).toBeUndefined();
});
it("schweigt, wenn eine Nummer gar nicht auf Ziffern endet", () => {
expect(naechsteOrgnummer(["OE-0001", "Vertrieb"])).toBeUndefined();
});
it("schweigt im leeren Bestand", () => {
expect(naechsteOrgnummer([])).toBeUndefined();
});
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
anzahlKriterien,
AUSWAHL_KRITERIEN,
beschreibeKriterien,
JANEIN_KRITERIEN,
kriterienParameter,
LEERE_CRITERIA,
@@ -16,15 +17,15 @@ function aus(params: Record<string, string>) {
describe("parseCriteria", () => {
it("nimmt bekannte Werte an", () => {
const c = aus({ employment: "Teilzeit", paygrade: "C", gender: "w" });
expect(c.auswahl).toEqual({ employment: "Teilzeit", paygrade: "C", gender: "w" });
const c = aus({ employment: "Teilzeit", paygrade: "HG13", gender: "w" });
expect(c.auswahl).toEqual({ employment: "Teilzeit", paygrade: "HG13", gender: "w" });
});
it("verwirft Werte, die nicht in der Liste stehen", () => {
// Die Werte landen in SQL-Vergleichen und im Dateinamen des Downloads,
// also in einem Content-Disposition-Header — was nicht in der Liste
// steht, darf nicht durchkommen.
const c = aus({ employment: "Vollzeit'; drop table employees; --", paygrade: "Z", gender: "x" });
const c = aus({ employment: "Vollzeit'; drop table employees; --", paygrade: "HG21", gender: "x" });
expect(c.auswahl).toEqual({});
});
@@ -58,6 +59,52 @@ describe("parseCriteria", () => {
});
});
describe("beschreibeKriterien", () => {
it("sagt nichts, wenn nichts eingeschränkt ist", () => {
expect(beschreibeKriterien(LEERE_CRITERIA)).toEqual([]);
});
it("nennt jedes gesetzte Kriterium beim Namen", () => {
const c = aus({ employment: "Teilzeit", dienstwagen: "ja", entryFrom: "2024-01-01", hoursTo: "30" });
expect(beschreibeKriterien(c)).toEqual([
"Beschäftigungsart: Teilzeit",
"Dienstwagen: ja",
"Eintritt ab 01.01.2024",
"Wochenstunden bis 30",
]);
});
it("unterscheidet ab, bis und den geschlossenen Zeitraum", () => {
expect(beschreibeKriterien(aus({ entryFrom: "2020-06-01" }))).toEqual(["Eintritt ab 01.06.2020"]);
expect(beschreibeKriterien(aus({ entryTo: "2020-06-01" }))).toEqual(["Eintritt bis 01.06.2020"]);
expect(beschreibeKriterien(aus({ entryFrom: "2020-01-01", entryTo: "2020-12-31" }))).toEqual([
"Eintritt 01.01.2020 bis 31.12.2020",
]);
});
it("zeigt die Beschriftung, nicht den gespeicherten Wert", () => {
// Bei der Austrittsart gehen die beiden auseinander; auf der Karte nützt
// nur das, was auch im Auswahlfeld steht.
const kriterium = AUSWAHL_KRITERIEN.find((k) => k.key === "austrittsart")!;
const option = kriterium.optionen[0];
expect(beschreibeKriterien(aus({ austrittsart: option.wert }))).toEqual([`${kriterium.label}: ${option.label}`]);
});
it("beschreibt so viele Kriterien, wie anzahlKriterien zählt", () => {
// Die Zahl am Aufklapper und der Text auf der Exportkarte dürfen nicht
// auseinanderlaufen: sonst meldet die eine Stelle eine Einschränkung, die
// die andere nicht benennt.
const alle = {
...Object.fromEntries(AUSWAHL_KRITERIEN.map((k) => [k.key, k.optionen[0].wert])),
...Object.fromEntries(JANEIN_KRITERIEN.map((k) => [k.key, "ja"])),
...Object.fromEntries(ZEITRAUM_KRITERIEN.map((k) => [`${k.key}From`, "2024-01-01"])),
hoursFrom: "10",
};
const c = aus(alle);
expect(beschreibeKriterien(c)).toHaveLength(anzahlKriterien(c));
});
});
describe("kriterienParameter", () => {
it("nennt jeden Parameter, den parseCriteria liest", () => {
// „Zurücksetzen" räumt anhand dieser Liste auf. Fehlt darin ein Name,

View File

@@ -7,6 +7,7 @@ import {
groupKeysFor,
measureValue,
MEASURE_LABELS,
messgroesseLabel,
type OrgLookups,
type ReportEmployee,
type ReportEvent,
@@ -27,7 +28,7 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
exit_date: null,
weekly_hours: 38.5,
source: "Extern",
paygrade: "B",
paygrade: "HG13",
birth_date: "1990-01-01",
gender: "w",
worker_type: "Angestellte:r",
@@ -179,6 +180,33 @@ describe("measureValue", () => {
});
});
describe("messgroesseLabel", () => {
// „Headcount" stand auf der Übersicht für die Aktiven und im Bericht für
// eine andere Zahl (N.02). Der Kunde führt zwei Grössen mit festen Namen;
// die Kennzahl im Bericht zählt aber, was ausgewählt ist.
it("nennt die beiden Grössen des Kunden beim Namen", () => {
expect(messgroesseLabel("headcount", ["Aktiv"])).toBe("Headcount Aktiv");
expect(messgroesseLabel("headcount", ["Aktiv", "Karenz"])).toBe("Headcount Aktives Dienstverhältnis");
expect(messgroesseLabel("headcount", ["Karenz", "Aktiv"])).toBe("Headcount Aktives Dienstverhältnis");
});
it("schreibt sonst dazu, was gezählt wurde", () => {
// Mit Anzeigenamen: „Karenz" heisst in der Oberfläche überall
// „Langzeitabwesenheit".
expect(messgroesseLabel("headcount", ["Aktiv", "Karenz", "Geplant"])).toBe(
"Headcount (Aktiv, Langzeitabwesenheit, Geplant)"
);
expect(messgroesseLabel("headcount", ["Ausgetreten"])).toBe("Headcount (Ausgetreten)");
});
it("lässt die übrigen Kennzahlen unberührt", () => {
// Nur beim Headcount hing an der Auswahl ein zweiter Begriff. „Ø Alter"
// der Aktiven heisst weiter „Ø Alter".
expect(messgroesseLabel("fte", ["Aktiv"])).toBe("FTE");
expect(messgroesseLabel("avg_age", ["Aktiv", "Karenz"])).toBe("Ø Alter");
});
});
describe("aggregateReport", () => {
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
const employees = [
@@ -251,6 +279,30 @@ describe("aggregateReport", () => {
const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups);
expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]);
});
it("sorts Hay-Grades aufsteigend statt nach Häufigkeit", () => {
// Die Leiter ist die Aussage: HG09 unten, HG20 oben. Nach Kennzahl
// sortiert stünde hier HG13 vorn, weil dort zufällig zwei Personen
// sitzen — und die Reihenfolge läse sich als Zufall.
const employees = [
emp({ id: "1", paygrade: "HG20" }),
emp({ id: "2", paygrade: "HG09" }),
emp({ id: "3", paygrade: "HG13" }),
emp({ id: "4", paygrade: "HG13" }),
emp({ id: "5", paygrade: "HG19P" }),
emp({ id: "6", paygrade: "HG19" }),
];
const rows = aggregateReport(employees, "headcount", "paygrade", null, lookups);
expect(rows.map((r) => r.key)).toEqual(["HG09", "HG13", "HG19", "HG19P", "HG20"]);
});
it("stellt den Generic Grade vor die unterste Stufe", () => {
// Er ist keine Stufe, sondern ihr Fehlen. Zwischen HG13 und HG14 stünde
// er wie eine.
const employees = [emp({ id: "1", paygrade: "HG13" }), emp({ id: "2", paygrade: "-" })];
const rows = aggregateReport(employees, "headcount", "paygrade", null, lookups);
expect(rows.map((r) => r.key)).toEqual(["-", "HG13"]);
});
});
describe("deriveStatusAsOf", () => {