Commit Graph

11 Commits

Author SHA1 Message Date
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
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
9ded472d25 Freiwillig oder unfreiwillig wird erhoben, nicht abgeleitet
Migration 20260917100000. Ruecknahme einer eigenen Entscheidung, nach der
Erklaerung des Kunden am 17.09.2026.

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

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

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

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

rehire_employee raeumt sie mit dem Austritt weg. Die Funktion ist dabei
ausgeschrieben worden; die Selbstpruefung haelt fest, dass die beiden
Umstellungen, die sie schon hinter sich hatte (app_current_user_id statt
auth.uid, fester search_path), dabei nicht verlorengehen — genau das ist der
Fehler, den ein create-or-replace aus einer alten Vorlage leise macht.
2026-09-17 12:04:53 +02:00
cf0ea51f47 Mitarbeiterart als zweite Achse neben der Beschaeftigtengruppe
Anforderung 9 aus dem Workshop. Migration 20260915140000.

Im Dokument standen die beiden untereinander:

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

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

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

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

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

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

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

Dazu zwei Dinge, die auf dem Weg auffielen:

  * Der Nachtlauf wendete bei einer auf spaeter datierten Aenderung nur
    Person und Vertrag an — die ganze Gruppe "role" fiel weg. Betriebsrat,
    Dienstwagen, Kollektivvertrag, Arbeitstage, Teilzeit und
    Kuendigungsschutz wurden erfasst, in der Historie vermerkt, protokolliert
    und am Stichtag nicht geschrieben. Sichtbar wurde das nie. Die neuen
    Felder haetten den Fehler geerbt; er ist jetzt fuer alle behoben.
  * Der Mitarbeiter-Export filterte ohne Stichtag ueber die Spalte `status`,
    mit Stichtag ueber die Ableitung. Der Export nach "Ausgetreten" liess
    damit genau die Leute aus, die gerade ausgetreten sind.
2026-09-15 22:47:22 +02:00
f17d299045 Lehrling, and a field that stops being named after its values
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 10m51s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
worker_type had two values because the field was named after them:
"Angestellte:r / Arbeiter:in". Lehrlinge are the third social-insurance
category in Austria; until now they were filed as one of the other two,
which they are not -- and which skewed every report grouped by this
column by exactly those people.

Adding the value is one line. The label was the work: the field was
called after its two values in six places, and each of them becomes
wrong with a third. They now read "Beschaeftigtengruppe", the name the
import has used all along.

One label deliberately keeps the old wording: app_feld_karte() in the
database. That string is not a caption there but a key -- stored rows in
employee_history and pending_changes carry it, and the map is how
reverting or correcting a history entry finds the field again. Renaming
it without rewriting those rows would make every older entry for this
field unrevertable, and nobody would notice until they tried.

The migration's assertion reads pg_enum rather than comparing against
'Lehrling'::worker_type: migrations run inside a transaction, and
Postgres refuses to use a freshly added enum value in the transaction
that added it. This has not been run against a live database here -- the
CI migration job is the first real execution.

Three hand-kept lists of the same enum (reports, import, the form) now
have a test holding them to one another, each mutation-checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:28:49 +02:00
b87c8ad64c Remove Supabase
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00
d2f4a7aab7 Ask for a residence permit only where one is needed
Employees from outside the EU, the EEA and Switzerland need a residence
permit, and HR needs to know when it expires — about eighty people in
the current data, across Türkei, Serbien and Bosnien. Two columns, the
same shape as the dismissal protection: a flag and a date that only
means anything with it. The date is optional, because an open-ended
permit has none and a mandatory field would force an invented one.

The nationality coupling deliberately stays out of the database. Putting
it there would mean keeping the country list in two places — SQL and
lib/countries.ts, where the picker needs it anyway — so an EU accession
would become a migration instead of a line in a list. Worse, correcting
somebody's nationality would fail the constraint while the old permit
was still attached, which is exactly the moment someone is fixing a
mistake. The UI decides whether the fields appear, and clears them when
the nationality moves into the free-movement area.

So the list is the load-bearing part, and it is tested: 31 entries, all
of them values the picker can actually produce, no duplicates, no third
countries. A missing nationality reads as "no permit required" — an
unanswered question is a reason to record it, not to demand papers.

The permit shows on the Stammdaten tab only for the nationalities it
applies to. A line reading "Aufenthaltstitel: Nein" under an Austrian
citizenship would look like information rather than a question that does
not arise.

Filter by it and by when it expires — the question behind that being
"whose permit runs out next quarter" — plus columns in the export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:27:26 +02:00
f5ace8af2e Make the part-time arrangement a state you can report on
Last step moved the four part-time arrangements out of the absence list
and recorded the reason in the history text. That answered "what
happened" but not "who is in one right now", and the profile showed
nothing at all. So it becomes a real field: teilzeit_art, with an
optional end date.

The objection I raised then still holds — a state goes stale, because
nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is
the answer to it: with an end date a report decides for itself what is
still running instead of trusting that someone maintained the row. Left
empty it means "open end", which is an honest thing to say.

It runs through the ordinary change machinery rather than beside it. It
sits in app_feld_karte, so it shows up in the history as a field with
before and after, and can be corrected there like any other. The
description suffix from last step is gone — writing the same thing twice
is how two versions start disagreeing.

Reporting: filter by variant, by "in one at all", and by when it ends;
group headcount by variant, where the absence of one reads "Keine"
rather than a dash, because in a report that is an answer and not a gap.
Plus columns in the export and the import.

One gap found while rehearsing, and only because the probe happened to
pick a return date in the future: a scheduled return carries its payload
through pending_org_changes, and that payload did not include the
variant. Someone would have come back on reduced hours in April with the
reason gone. The daily run now carries it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:07:41 +02:00
6681dcda77 Flag people who cannot simply be dismissed
Works council members, expectant mothers, parents on leave, registered
disabled employees, apprentices — each has its own rules that come
before a dismissal. The tool does not judge whether one is lawful, but
it must not stay quiet about it either, and looking it up on the
contract tab is exactly the step that gets skipped under time pressure.

So: a checkbox, an optional end date, and a red warning at the top of
the termination panel naming the date — or saying plainly that no end
was recorded. It shows for a no-show too; the protection runs from the
start of the contract, not the first day worked.

The date is optional on purpose. A works council mandate has a known
end, a pregnancy does not, and a mandatory field would force an invented
number. A constraint says only what cannot be: an end date without the
flag, which would be a leftover nobody could interpret.

The field goes the whole way through — hire, data change, contract
sheet, export, report criteria (as a yes/no and as a date range), and
the import. A field that exists in one screen and not the next is how
people stop trusting the numbers.

Terminating is now offered for planned entries as well, labelled "Nicht
angetreten", with No Show preselected. Without it a person who never
turned up stayed a planned entry forever, since nothing else can end
one.

One finding worth recording: tsc has been reporting success on a broken
program. A generated file under .next got corrupted when a build ran
against a live dev server, and its syntax errors suppressed semantic
checking everywhere else — two genuine type errors in this change went
unreported until I typechecked with .next excluded. The file is removed
and the ordinary typecheck is meaningful again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:44:00 +02:00
0b8f874fa5 Let the export select on everything the data model holds
The export offered four criteria — unit, location, status, employment
type — while the employee record carries around twenty selectable
attributes. Anything else had to be filtered by hand in Excel afterwards,
which is how a payroll hand-off stops matching the application it came
from.

All of them are now filters: contract type, blue/white collar,
collective agreement, paygrade, internal/external, gender, company car
and its drivetrain, works council, lateral leadership, C-level, type of
long-term absence, weekday worked, dependents on file, and open ranges
for entry, exit, birth date and weekly hours. The unit filter covers
every level rather than only divisions, so a single department can be
selected without going the long way round.

They live in one table in lib/report-criteria.ts, which the filter panel
builds itself from, the parser validates against, and the query turns
into conditions. A new criterion is one entry there and nothing else —
and it cannot end up working in the report while being silently ignored
by the export.

The two export links and the saved-report config now carry the query
string through as it stands instead of listing the parameters they know
about. That enumeration was the actual defect: adding a filter meant
remembering three separate places, and forgetting one produced an export
that quietly disagreed with the figure on screen.

Validation is not housekeeping here. These values reach SQL comparisons
and the download filename, i.e. a Content-Disposition header; what is not
in the list does not get through.

The company car dropdown leaves the employee list. It is one of twenty
equals under Berichte now, where the selection can also be exported —
which was the point of asking in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:40:47 +02:00