c25c16e3724484b4124f0f9a8378d729d0e84b69
14 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 8d0c9b4b65 |
Workshop-Anforderungen, erster Teil: was ohne Migration geht
Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert — als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das Dropdown im Formular schraenkt die Auswahl darunter ein. Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite zuzuschlagen wuerde jede Fluktuationsquote verfaelschen. Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen. Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg; "Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines zusteht, nicht ob es uebergeben wurde. Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren vorher nur ueber den Export erreichbar. Stammdatenaenderung und Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt, Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export. Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet, hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste man selbst. |
|||
| 1cbed1a8f5 |
Der Status kommt aus den Daten, nicht aus der Spalte
Die Liste filterte ueber die Datumsspalten, beschriftete die Zeilen aber mit
employees.status. Sobald die Spalte nachhaengt, widersprechen sich die
beiden — und sie haengt regelmaessig nach: terminate_employee setzt sie nur,
wenn das Austrittsdatum nicht in der Zukunft liegt, und es gibt keinen Lauf,
der das spaeter nachzieht (Migration 20260814100000 sagt das selbst).
Beim Kunden waren beide Richtungen zu sehen. Der Filter "Ausgetreten" fand
48 Personen, von denen mehrere als "Aktiv" beschriftet waren; der Filter
"Geplant" zeigte Nichtantritte, deren Spalte laengst "Ausgetreten" trug.
StatusChip nimmt deshalb jetzt die Zeile und den Stichtag und leitet selbst
ab. Die Spalte laesst sich nicht mehr hineinreichen — die zweite Quelle ist
nicht bloss ungenutzt, es gibt sie an dieser Stelle nicht mehr.
Dazu drei Stellen, die an derselben Spalte hingen:
* Die Akte entschied mit ihr ueber die Knoepfe. An einer Person, die seit
zwei Wochen ausgetreten ist, stand "Austritt" weiter zur Verfuegung.
* Die Sortierung nach Status ordnete nach einem Wert, der nirgends auf der
Seite steht.
* Die Karte "Anstehend" zaehlte kuenftige Eintritte und Rueckkehren ueber
die Spalte und damit anders als die Liste, auf die sie verlinkt.
Und eine Klausel, die in der Ableitung fehlte: ein Nichtantritt traegt als
Austrittsdatum den Eintrittstag. Liegt der in der Zukunft, ist auch der
Austritt groesser als der Stichtag — die vorige Korrektur verglich nur gegen
den Stichtag und blieb damit wirkungslos. Endet ein Verhaeltnis nicht
spaeter, als es beginnt, gab es keinen Tag Beschaeftigung, zu keinem
Stichtag.
|
|||
| d2d5e1dabb |
Drei Befunde aus dem Test: Logo, Geplant-Filter, Anstehend-Farben
**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL. Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert aus §1.1. Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei — das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum. **Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte. employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang an das Richtige; falsch war die Ableitung in der Anwendung. Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab. Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht betroffen. **Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht "Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen, das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf. Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb, Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster Kontrast 5.49:1. Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war. Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser nicht gesehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| f17d299045 |
Lehrling, and a field that stops being named after its values
worker_type had two values because the field was named after them: "Angestellte:r / Arbeiter:in". Lehrlinge are the third social-insurance category in Austria; until now they were filed as one of the other two, which they are not -- and which skewed every report grouped by this column by exactly those people. Adding the value is one line. The label was the work: the field was called after its two values in six places, and each of them becomes wrong with a third. They now read "Beschaeftigtengruppe", the name the import has used all along. One label deliberately keeps the old wording: app_feld_karte() in the database. That string is not a caption there but a key -- stored rows in employee_history and pending_changes carry it, and the map is how reverting or correcting a history entry finds the field again. Renaming it without rewriting those rows would make every older entry for this field unrevertable, and nobody would notice until they tried. The migration's assertion reads pg_enum rather than comparing against 'Lehrling'::worker_type: migrations run inside a transaction, and Postgres refuses to use a freshly added enum value in the transaction that added it. This has not been run against a live database here -- the CI migration job is the first real execution. Three hand-kept lists of the same enum (reports, import, the form) now have a test holding them to one another, each mutation-checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 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> |
|||
| 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> |
|||
| 8a76b3688f |
Show people surname first
Employee names now read "Winkler, Hannah" wherever a person appears in a list, a table, a heading or a tree node. That is the order a personnel list is kept in, it is the order people are looked up in, and it finally matches the sorting — the employee list has always been ordered by surname, which made an alphabetical page look unsorted. The name was being assembled inline in about twenty places. A rename that catches half of them is worse than none, so it now goes through fmtName in lib/format.ts and every display site calls it. Sentences keep the natural order: "Hannah Winkler wurde versetzt" reads like German, "Winkler, Hannah wurde versetzt" reads like a form. So the toasts are unchanged and only labels moved. Two things the change would have quietly broken: The org chart's own filter matched against "first last". It now matches either order, with or without the comma, so typing what you see works and so does typing what you remember. The print model sorted by the last word of the composed name, which happened to be the surname and is now the first name — every printed unit would have come out sorted by first name. It sorts on the surname field itself now, which is what it meant all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 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> |
|||
| 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.
|
|||
| 8282d7f581 |
Rename Karenz to Langzeitabwesenheit and record its type
Karenz was doing duty as the name for every kind of extended absence, but the cases behave differently in payroll and reporting — Wochenhilfe, a Präsenzdienst, a long sick leave and a sabbatical are not the same thing. The concept is now called Langzeitabwesenheit and carries which kind it is. - employees.absence_type, constrained to the thirteen kinds. start_karenz stores it on both paths (written straight away, or parked in the pending_org_changes payload when the absence starts later); record_karenz_return and the karenz_return branch of apply_due_pending_changes clear it, so a returned employee does not keep looking like they are still away. It also reaches employee_history, the audit log and the employee export. - The status enum value stays 'Karenz'. Postgres can rename an enum value in place, but every stored function body that spells it would then reference a value that no longer exists — a dozen functions across fifteen migrations, rewritten for a label. The mapping lives in lib/absence.ts instead, which is the single place the UI reads the display name from. - Where a kind is recorded the chip shows it — "Bildungskarenz" says more than "Langzeitabwesenheit". Absences predating the field have none and fall back to the generic name rather than to a guess, and a value outside the list is dropped rather than echoed into the UI. - The export prints the display name, not the raw enum: a payroll hand-off reading "Karenz" for what the app calls Langzeitabwesenheit only causes questions. Audit filter options keep their stored values and change only their labels. - The seed spreads the twelve absences across the kinds; all of them being Karenz would leave any breakdown by kind invisible. |
|||
| 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.
|
|||
| 901c5c426e |
Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`). |
|||
| e27db5f030 |
Phase 6/7: Reports builder and Audit log - all 7 routes now complete
Reports (§4.8): - lib/reports.ts: generic server-side aggregation engine over 9 measures (Headcount, FTE, Eintritte, Austritte, Ø Bruttogehalt, Teilzeitquote, Ø Alter, Ø Zugehoerigkeit, Frauenanteil) x 10 group-by dimensions, with an optional second-dimension split (disabled for average-type measures) and per-row drill-down data. - app/(app)/reports/page.tsx: reads filters from the URL, fetches the matching employees_directory rows server-side, aggregates in Node (not shipped raw to the client), computes the total. - ReportsPageClient: measure/group/split/filter controls, 6 preset chips, saved-reports list (actions/reports.ts), CSV export (client-side blob download), stacked bars with a color-keyed legend when split is active, and click-to-drill-down into the underlying people (capped at 12, "+N weitere", linking to /employees/[id]). Audit log (§4.9): - app/(app)/audit/page.tsx + AuditFilters: search (target/details/actor) + action-type filter, paginated table with colored action badges, row links to the affected employee when target_employee_id is set, and the required "unveraenderbar" footer note. This completes every route from the spec's information architecture: Dashboard, Mitarbeiter:innen (list+detail), Organigramm (3 views), Positionen & Bereiche, Berichte, Audit-Log, plus the Hire wizard and all 6 action panels reachable from them. Final verification: clean npm run build + tsc --noEmit, then a full browser walkthrough of all 6 authenticated routes as hr_admin (zero console errors, zero 5xx responses) and a role-check pass as the manager test account confirming action buttons and the "+ Neueinstellung" button are hidden, and salary is masked as "... (ausgeblendet)" on the Vertrag & Gehalt tab. Swept the database for leftover test data from the debugging sessions above - none found, seed data is clean. |