Commit Graph

15 Commits

Author SHA1 Message Date
410acfe01f Cornerstone Report: system values, not display names
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m34s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Reworked against the load spec and the 27.09. test file. The values I
had guessed were the German display names, which is the first entry on
the list of errors from earlier loads: Cornerstone answers "ungültiger
Wert" and rejects the whole row, followed by "Alle abhängigen Felder
müssen gültig sein" as a follow-on.

  Status            Aktiv/Inaktiv    -> Active/Inactive
  Employment Status Arbeitend/...    -> Working/On Leave/Terminated
  User Type         Mitarbeiter      -> Employee
  Time Zone         CET              -> empty

The time zone is the second entry on that list: only a portal time zone
id is valid, an abbreviation gives "Zeitzonencode nicht eindeutig".
Empty means the portal or the OU decides.

Division ID is the GUID from the test file, not a name. Termination
fields and Leave Reason are filled only when the employment status
carries them -- a reason without a termination is an invalid state for
the load, not extra information.

Four fields now stay empty on purpose, because filling them would mean
inventing an identifier that belongs to the target system: Location ID
(locations has id/name/country and no Cornerstone id), Position ID (our
S-0001 is not a Cornerstone position), Months of Service (Cornerstone
derives it) and Rehired Employee (the value for "yes" is unconfirmed,
and an unconfirmed value costs the whole row). Retention Rules and
Organisationsstufe stay empty because the load ignores them.

Header and row now match the test file byte for byte, except User ID
(no TEST- prefix outside a test load), Required Training Approvals and
Location ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:10:56 +02:00
478364e2d0 Add the Cornerstone export to Berichte
The 57 columns of the Cornerstone user import, in the given order and
spelling, offered as CSV and Excel under the Honestly report. Same
people and same filters as the full export.

Two things about the file itself, both of which would have failed
quietly. Cornerstone reads comma-separated, while toCsv writes the
semicolons and the BOM that German Excel wants -- the BOM would have
hidden inside the name of the first column, so "User ID" would have
matched nothing in the mapping. toCsv now takes the form as an argument
and keeps its old defaults; the Cornerstone form lives next to the
columns so a test can hold both. Quoting follows the delimiter now,
otherwise a comma in an address would split the row.

Dates go out day-first, matching the import setting. An ISO date is
read as a different, equally valid date and nobody notices.

Gender maps to Cornerstone's own values; anything unexpected becomes
"not specified" rather than empty, because an invalid value makes
Cornerstone reject the whole row, not just the field.

Status and Employment Status come from separate rules: somebody on
Karenz has a working account and is not working, and filling both from
one value gets one of the two wrong.

Four fields carry visible placeholders because the leading systems do
not supply their identifiers yet: Division ID, Doxis, Interflex,
LGVplus. Home Phone and Personal Email stay empty on purpose -- what
leaves the house is the business data.

Not verified against a running Cornerstone import, and not seen in a
browser; there is no database reachable here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 11:21:25 +02:00
db02fb4762 Elf Punkte aus der Rueckmeldung, und die verlorene Anmerkung
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m23s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m18s
Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel
"Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte
(Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn.
"Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der
Behinderung und der Erklaertext ueber dem Honestly-Report sind weg.
"Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt
ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und
ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler.

Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine
Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern"
wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im
Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte
Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse.

"Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer
"Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt.
Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den
keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe
Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine
Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden
Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still
auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben
bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige.

Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends.
Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis
20260814100000 stand sie in der Beschreibung des Ereignisses; beim
Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration
fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht
jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung
faengt den naechsten Verlust ab.
2026-09-23 16:01:05 +02:00
683e7cc2d7 Keep private email out of the Honestly export
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
The Email column exported employees.email, which is the person's
private address. That does not belong in a file sent to an outside
survey provider, least of all as the address invitations go to in the
employer's name.

The column now stays as a placeholder for the work email, which does
not exist in the schema yet and will be added later. It is empty until
then, but keeps its place so the column mapping set up in Honestly does
not have to change once the address arrives. The export no longer
reads employees.email at all, so it cannot end up in another column
by accident either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:10:44 +02:00
779d6116b2 Add the Honestly survey export to Berichte
A participant list for the employee survey on honestly.de, offered as
CSV and Excel right under the full data export. Columns: Personalnummer,
Email, Firstname, Last Name, Language, Location, OU, OU+1, ... , Role.
Language is always "de", Role always "Respondee".

The org columns run bottom-up: OU is the person's own unit, OU+1 the one
above, up to the top node. Their number follows the deepest chain in
the file, shorter chains are padded with empty cells, and there is
always at least one OU column so the file keeps its shape.

It exports exactly the people the full export would, with the same
filters. To guarantee that, the selection moved out of the full export's
route into lib/export-auswahl.ts and both routes use it; the full
export's columns are untouched.

Email is employees.email, which is the private address and optional --
there is no work address in the schema. Missing addresses stay empty
rather than getting a placeholder that would receive an invitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:10:43 +02:00
8d0c9b4b65 Workshop-Anforderungen, erster Teil: was ohne Migration geht
Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der
Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der
Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei
neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben
beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert
— als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das
Dropdown im Formular schraenkt die Auswahl darunter ein.

Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes
Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite
zuzuschlagen wuerde jede Fluktuationsquote verfaelschen.

Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen.

Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg;
"Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines
zusteht, nicht ob es uebergeben wurde.

Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren
vorher nur ueber den Export erreichbar. Stammdatenaenderung und
Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder
geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das
Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt,
Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export.

Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht
dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet,
hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste
man selbst.
2026-09-15 22:35:47 +02:00
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
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
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.
2026-07-25 14:34:28 +02:00
d9367a8ce4 Form primitives, keyboard-operable comboboxes, dialog focus, route states
Accessibility work on the UI layer, all of it rooted in one structural gap:
there were no form primitives, so every field was hand-assembled and every
field got the same details wrong.

Form primitives
- components/ui/Field.tsx (Field/TextField/SelectField/TextareaField) and
  Button.tsx. Field generates the control id with useId and derives htmlFor
  from it, which is what makes the association impossible to omit rather
  than merely conventional.
- 92 labels existed, 4 used htmlFor, and no input carried an id at all: a
  screen reader announced an unnamed edit box and clicking a label focused
  nothing. Now every label resolves to its control (0 unassociated), and the
  input class chain that appeared verbatim 85 times appears zero times.
- Field also takes a render prop, so Lookup, CountryPicker and Picklist get
  the same wiring instead of a second, partial solution.
- SearchInput replaces three hand-rolled copies of the icon-in-a-box search
  whose input had only a placeholder — not a label — and killed its own
  focus ring with outline-none and nothing in its place.
- Toggle groups (workdays, reorg change type) became fieldsets with
  aria-pressed; colour alone was carrying the selected state.

Comboboxes
- Lookup and CountryPicker were text inputs with a div of clickable buttons
  underneath: typeable, but no keyboard path to a result and nothing telling
  a screen reader a list had appeared. Both now carry role=combobox,
  aria-expanded/controls/activedescendant and listbox semantics, with arrow
  keys, Enter and Escape. Escape stops propagation, or it would close the
  surrounding dialog along with the dropdown.

Dialogs
- useDialogFocus centralises what Modal and SlideOver each owed the
  keyboard and neither provided beyond Escape: focus into the dialog on
  open, Tab and Shift+Tab cycling within it, focus restored to the trigger
  on close.
- SlideOver stays mounted for its transition, and aria-hidden does not
  remove anything from the tab order — so every closed panel was leaving
  invisible tab stops at the end of the page. `inert` fixes that.

Route states
- loading.tsx, error.tsx, not-found.tsx and global-error.tsx. Every page in
  the (app) group is server-rendered per request, so without loading.tsx a
  navigation showed nothing at all until the server answered, and a render
  error dropped the user on Next's own screen with no way back.

Tests
- 22 component tests (vitest jsdom project). Two of them found limits of the
  environment rather than of the code: jsdom implements neither `inert` nor
  scrollIntoView, so the inert test asserts the attribute and the missing
  scrollIntoView — which was taking the whole render down from inside an
  effect — is stubbed in the setup file.
2026-07-25 13:11:09 +02:00
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.
2026-07-24 23:38:10 +02:00
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.
2026-07-15 20:34:27 +02:00
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`).
2026-07-14 20:32:20 +02:00
f7e5cd6e6e Fix Bericht speichern: window.prompt() is unsupported in this browser environment, replaced with a proper Modal dialog 2026-07-13 23:39:54 +02:00
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.
2026-07-13 23:11:23 +02:00