96c94642ba651f72588fbee9c83ae8a59af18e66
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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.
|
|||
| cf0ea51f47 |
Mitarbeiterart als zweite Achse neben der Beschaeftigtengruppe
Anforderung 9 aus dem Workshop. Migration 20260915140000.
Im Dokument standen die beiden untereinander:
Arbeiter, Angestellte, Lehrlinge
Standard / Praktikant / Geringfuegige Beschaeftigung / Altersteilzeit
Die erste Zeile gibt es schon als worker_type, Lehrling seit 20260910140000.
Die zweite ist eine andere Frage: nicht *als was* jemand angestellt ist,
sondern *in welcher Form*. Beide gelten gleichzeitig — ein Praktikant ist
Arbeiter oder Angestellter, nicht statt dessen. In eine Liste gepresst
muesste man sich fuer eine der Antworten entscheiden und verloere die andere.
NOT NULL mit Vorgabe "Standard": jede Person ist in irgendeiner Form
beschaeftigt, und "nicht erfasst" waere keine Aussage, sondern eine Luecke,
die sich durch jede Auswertung zieht. Der Bestand bekommt damit einen
sichtbaren, korrigierbaren Ausgangswert statt eines leeren Feldes.
Die vier Funktionen wurden vollstaendig neu geschrieben. Die Selbstpruefung
haelt deshalb auch die Felder der vorigen Migration fest: eines beim
Uebertragen zu verlieren waere ein Fehler, der nirgends auffiele — die
Oberflaeche schickte den Wert weiter, und die Funktion ignorierte ihn.
|
|||
| c25c16e372 |
Kuendigungsschutz bekommt einen Personenkreis, die Behinderung eigene Felder
Anforderungen 5 und 5a aus dem Workshop. Migration 20260915120000.
Bisher gab es ein Kennzeichen und ein Enddatum. Das beantwortet "darf hier
ohne Weiteres beendet werden?" — nicht aber, *warum* jemand geschuetzt ist,
und davon haengt ab, wer zustimmen muss. Nachzusehen war das nur im
Papierakt, also dort, wo unter Zeitdruck niemand nachsieht.
Zwoelf Personenkreise als CHECK auf text und nicht als Aufzaehlungstyp: die
Liste ist Rechtslage und aendert sich mit dem Gesetz, ein Typ liesse einen
zurueckgenommenen Wert fuer immer stehen. Die Oberflaeche liest dieselbe
Liste aus lib/kuendigungsschutz.ts; ein Test liest die Migration und haelt
beide gegeneinander, damit die begruendete Doppelung keine stille wird.
Die begueenstigte Behinderung bekommt Kennzeichen, Grad, Beginn und Ende —
vier Spalten und nicht eine zusammengesetzte, weil in Excel danach
gefiltert und summiert wird. Datenbankseitig sind sie *nicht* an den
Personenkreis gekettet: eine solche Bedingung scheiterte genau dann, wenn
jemand den Kreis korrigiert und der Grad noch dransteht. Die Oberflaeche
stellt den Zusammenhang her.
Dazu zwei Dinge, die auf dem Weg auffielen:
* Der Nachtlauf wendete bei einer auf spaeter datierten Aenderung nur
Person und Vertrag an — die ganze Gruppe "role" fiel weg. Betriebsrat,
Dienstwagen, Kollektivvertrag, Arbeitstage, Teilzeit und
Kuendigungsschutz wurden erfasst, in der Historie vermerkt, protokolliert
und am Stichtag nicht geschrieben. Sichtbar wurde das nie. Die neuen
Felder haetten den Fehler geerbt; er ist jetzt fuer alle behoben.
* Der Mitarbeiter-Export filterte ohne Stichtag ueber die Spalte `status`,
mit Stichtag ueber die Ableitung. Der Export nach "Ausgetreten" liess
damit genau die Leute aus, die gerade ausgetreten sind.
|
|||
| 8d0c9b4b65 |
Workshop-Anforderungen, erster Teil: was ohne Migration geht
Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert — als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das Dropdown im Formular schraenkt die Auswahl darunter ein. Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite zuzuschlagen wuerde jede Fluktuationsquote verfaelschen. Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen. Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg; "Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines zusteht, nicht ob es uebergeben wurde. Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren vorher nur ueber den Export erreichbar. Stammdatenaenderung und Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt, Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export. Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet, hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste man selbst. |
|||
| b87c8ad64c |
Remove Supabase
The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| d2f4a7aab7 |
Ask for a residence permit only where one is needed
Employees from outside the EU, the EEA and Switzerland need a residence permit, and HR needs to know when it expires — about eighty people in the current data, across Türkei, Serbien and Bosnien. Two columns, the same shape as the dismissal protection: a flag and a date that only means anything with it. The date is optional, because an open-ended permit has none and a mandatory field would force an invented one. The nationality coupling deliberately stays out of the database. Putting it there would mean keeping the country list in two places — SQL and lib/countries.ts, where the picker needs it anyway — so an EU accession would become a migration instead of a line in a list. Worse, correcting somebody's nationality would fail the constraint while the old permit was still attached, which is exactly the moment someone is fixing a mistake. The UI decides whether the fields appear, and clears them when the nationality moves into the free-movement area. So the list is the load-bearing part, and it is tested: 31 entries, all of them values the picker can actually produce, no duplicates, no third countries. A missing nationality reads as "no permit required" — an unanswered question is a reason to record it, not to demand papers. The permit shows on the Stammdaten tab only for the nationalities it applies to. A line reading "Aufenthaltstitel: Nein" under an Austrian citizenship would look like information rather than a question that does not arise. Filter by it and by when it expires — the question behind that being "whose permit runs out next quarter" — plus columns in the export. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| f5ace8af2e |
Make the part-time arrangement a state you can report on
Last step moved the four part-time arrangements out of the absence list and recorded the reason in the history text. That answered "what happened" but not "who is in one right now", and the profile showed nothing at all. So it becomes a real field: teilzeit_art, with an optional end date. The objection I raised then still holds — a state goes stale, because nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is the answer to it: with an end date a report decides for itself what is still running instead of trusting that someone maintained the row. Left empty it means "open end", which is an honest thing to say. It runs through the ordinary change machinery rather than beside it. It sits in app_feld_karte, so it shows up in the history as a field with before and after, and can be corrected there like any other. The description suffix from last step is gone — writing the same thing twice is how two versions start disagreeing. Reporting: filter by variant, by "in one at all", and by when it ends; group headcount by variant, where the absence of one reads "Keine" rather than a dash, because in a report that is an answer and not a gap. Plus columns in the export and the import. One gap found while rehearsing, and only because the probe happened to pick a return date in the future: a scheduled return carries its payload through pending_org_changes, and that payload did not include the variant. Someone would have come back on reduced hours in April with the reason gone. The daily run now carries it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 6681dcda77 |
Flag people who cannot simply be dismissed
Works council members, expectant mothers, parents on leave, registered disabled employees, apprentices — each has its own rules that come before a dismissal. The tool does not judge whether one is lawful, but it must not stay quiet about it either, and looking it up on the contract tab is exactly the step that gets skipped under time pressure. So: a checkbox, an optional end date, and a red warning at the top of the termination panel naming the date — or saying plainly that no end was recorded. It shows for a no-show too; the protection runs from the start of the contract, not the first day worked. The date is optional on purpose. A works council mandate has a known end, a pregnancy does not, and a mandatory field would force an invented number. A constraint says only what cannot be: an end date without the flag, which would be a leftover nobody could interpret. The field goes the whole way through — hire, data change, contract sheet, export, report criteria (as a yes/no and as a date range), and the import. A field that exists in one screen and not the next is how people stop trusting the numbers. Terminating is now offered for planned entries as well, labelled "Nicht angetreten", with No Show preselected. Without it a person who never turned up stayed a planned entry forever, since nothing else can end one. One finding worth recording: tsc has been reporting success on a broken program. A generated file under .next got corrupted when a build ran against a live dev server, and its syntax errors suppressed semantic checking everywhere else — two genuine type errors in this change went unreported until I typechecked with .next excluded. The file is removed and the ordinary typecheck is meaningful again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 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> |
|||
| b3a0af2b8f |
Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.
Was dabei ersatzlos verschwindet:
- fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
Am direkten Zugang ist eine Abfrage eine Abfrage.
- sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
abgesichert, sondern weg.
- lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.
Nebenbei besser geworden, weil der direkte Zugang es erlaubt:
- Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
Lesestand statt in fünf unabhängigen Anfragen.
- Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
eingebetteten Ressource mit !inner — eine Person mit mehreren
Zuordnungen über die Zeit erschien dort mehrfach.
- Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
- Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
- Namen an Ereigniszeilen kommen aus einem Join statt aus einem
Nachschlag, der ausserhalb der Transaktion lag.
Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.
Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.
Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
|
|||
| 27669e0359 |
Put the whole application on the OM model, and delete what it replaced
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
|
|||
| 79f0e19bf8 |
Org assignment history, mobile support, and a correctness pass
Data model - employee_assignments records org placement over time (valid_from/valid_to), written by a trigger on `employees` rather than inside each RPC: ~70 `update employees` statements spread over fifteen migrations mean per-call bookkeeping would miss paths today and again with every future RPC. A partial unique index enforces the one-open-interval invariant the trigger relies on when closing the current row. - The Organigramm gains a Stichtag (default today). Membership comes from entry/exit/karenz, past placement from the new history, future placement projected from pending_org_changes. Placements predating the migration are backfilled with today's values and flagged as such in the UI, since employee_history only ever stored free text and cannot be reconstructed. Correctness - Reports and exports silently truncated at PostgREST's 1000-row cap (db.max_rows); employee_history is already past it at ~800 staff. Every whole-table read now pages explicitly. - XLSX date cells were a day early: ExcelJS converts a Date to an Excel serial straight off getTime(), so a Date built at local midnight lands on the previous day's serial in any positive-offset zone. - Date handling is pinned to Europe/Vienna throughout, and date-only strings are formatted without a Date round-trip. The dashboard's YTD window was built by round-tripping a local Date through toISOString(), which shifted it a day early and dropped 31 December entirely. - Export routes parsed measure/group/split/eventType with unchecked `as` casts, so an unknown value reached column headers as `undefined` and the Content-Disposition filename. Parsed against the label maps now, with the filename slugged as a backstop. - toXlsx keyed columns by header text, silently dropping the second of any two columns sharing a name — split columns take their header from data. - The org chart tree walks had no cycle guard; nothing in the schema forbids a manager_id cycle, and one would hang the tab rather than misreport. - The login page reflected ?error= verbatim, letting anyone put arbitrary text on the real sign-in screen; messages are looked up by code now. - React Flow needs elementsSelectable on, or it sets pointer-events:none on the whole node and the expand control stops responding. UI - Mobile: the shell was unusable below lg — a fixed 236px margin pushed content off-screen with no mobile navigation at all. The sidebar is now a drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so iOS stops zooming on focus, and form grids stack. - Org chart nodes redesigned: per-kind accent stripes and icons, vacant roles called out, expand control moved to the bottom edge carrying the child count. - Pagination is windowed; it previously rendered one link per page (54 for the employee list, unbounded for the audit log). - Positions page reduced to open positions with a single "Besetzen" action. - The employee Organisation tab links into the org chart focused on that person, reusing the chart's existing search-match highlighting. Also included, uncommitted until now - Dependants, HR notes, academic titles, split address fields, position validity and role/employment fields, with their migrations and UI. - Docker/compose deployment setup, data-model and security-review docs. |
|||
| f96773da0f |
Reports/Export builder (CSV/XLSX), plus a security fix pass
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.
Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
|