c25c16e3724484b4124f0f9a8378d729d0e84b69
12 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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.
|
|||
| 2838919e42 |
@
Name every draft, and put Vertrag before Angehoerige Two things the dashboard and the hire wizard were getting wrong. The drafts card named the author only on other people's drafts. With foreign and own rows side by side that reads as an inconsistency, not as information: the eye has to work out that a missing name means "mine". Now every row says it, "von mir" on the own ones -- the same wording the notes in the bell already use. In the wizard, Angehoerige stood before Vertrag. What a contract is made of -- entry date, working days, a fixed term -- is on paper before the conversation happens; relatives the person brings along, often on the first day. The optional step came before the one the hire rests on. Swapping them meant touching the part that would have broken silently: the per-step validation was a positional list that had to line up with STEP_LABELS by hand. Reordered labels alone would have left the checks where they were -- "Weiter" on Vertrag would have validated the relatives and waved an empty entry date through, until the database refused it at the end. The checks are keyed by step name now, so they travel with the step. Drafts saved before this land on the step number they stored, which now points at a different page. Nothing is lost -- the payload carries every field -- but somebody resuming an older draft may open on Vertrag where they left Angehoerige. 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> |
|||
| c23df08648 |
Ask for the optional things separately, and stop claiming numbers are issued
A round of interface corrections from use, plus one schema change behind them. The private email address is now optional. It was NOT NULL — the wrong default for a private detail: someone without one had to invent one, and invented data in a personnel file is worse than missing data. Both fields are relabelled to say whose they are, "Private E-Mail" and "Private Telefonnummer", because the company address does not exist until the person starts. Uniqueness stays; several NULLs coexist in a Postgres unique index, which is exactly what is wanted. The summary step still promised that "Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben". Neither is true any more. Removed rather than reworded — the step lists what was entered, and a banner claiming otherwise is worse than no banner. Dependents move into the wizard as step three, optional. They can only be attached after the hire, because add_employee_dependent needs an id that does not exist while the form is open, so they are collected in the draft and written afterwards. That puts them outside the transaction the person is created in: if one fails the person still exists, so the message names who is missing instead of failing silently, and the SV number is checked in the step rather than after. The emergency contact gets its own step, second to last, and its relationship is a dropdown of the common ones rather than free text — otherwise "Gattin", "Ehefrau" and "Frau" end up side by side and nothing can be counted. "Sonstige" is there because a closed list would otherwise be presumptuous. On the master-data tab it now sits below the dependents rather than above: both are people around the employee, and this is the one you reach for in a hurry. Returning from a long absence: the choice read "unverändert", which made you open the file to find out what you were agreeing to. It now reads "Wie vor Abwesenheit (38,5 h)" with the hours actually worked, and the alternative is "Reduziert" — whose hours field starts empty on purpose. A number already filled in gets confirmed rather than read off the agreement it comes from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 9d754359e0 |
Enter the personnel number, tell the two kinds of company car apart, record who to call
Three requests from use, one of which changes the schema's mind about
something.
The personnel number is no longer issued. It was GENERATED ALWAYS AS
IDENTITY, which refuses a supplied value outright — but it has to match Loga
and Interflex, and a number this application invents is unknown there, so the
same person ends up with two. Identity dropped, entered everywhere instead:
in the wizard, in the import, and validated against a duplicate with a
message that names the number.
Worth stating plainly: the column had no unique constraint. The identity
prevented collisions as a side effect, and once the value comes from outside
that side effect is gone. The constraint is the point now, and it was
missing.
Company cars distinguish Verbrenner from Elektro, tied to has_dienstwagen by
a CHECK so "E-KFZ" cannot appear against someone without a car. The list
filters on it — with, without, only electric, only combustion — which is the
question the report was really about; it was answerable before only through
an export and manual work.
Emergency contact is name, phone and relationship. Relationship stays free
text: the examples given — Gattin/Gatte, Schwester/Bruder, Freund — are not
a list that closes without telling someone their arrangement does not count.
Name and phone are all-or-nothing, in the database and in both forms: a name
without a number helps nobody, a number without a name does not say who
answers.
Two mistakes of mine on the way, both caught by checks I had written into
the migrations rather than by me:
- The first CHECK on the car type would have permitted exactly the case it
was written against. `art in (…)` yields NULL rather than false when the
column is null, and a CHECK counts NULL as satisfied. It needs an
explicit `is not null` in front.
- The constraint was added before the backfill, so it rejected every
existing row with a car.
Existing cars are recorded as Verbrenner, which is an assumption — but a
visible one: "Elektro" appears nowhere nobody confirmed it.
hire_employee and change_employee_data both had to learn the new columns.
They name their columns one by one, and what is missing there is dropped in
silence — the interface would have collected the fields and thrown them
away, which is what happened to the email address this morning.
Verified against the live database, all rolled back: a hire without a number
is refused, a duplicate is refused naming it, a freely chosen one goes
through; E-KFZ plus contact arrive intact; a contact without a phone is
refused. A change records both, with before and after in the audit detail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| e44f71a60d |
Stop offering positions that are already spoken for, and let hiring work again
Two reports, four defects, all of them in the way of ordinary use.
A position with a signed starter is not vacant. loadOpenPositions asked "is
anyone on it today?", so three positions whose new holders begin in
September and October were listed as open, labelled "vacant for 2 days".
That same list feeds the hire wizard, so it invited filling a position a
second time — discovered at the partial unique index, after the second
interview. Vacancy now means no assignment that still stands, including one
that has not started. An assignment that ended still frees the position.
Hiring was broken three times over, each fault hidden behind the previous
one:
1. hire_employee cast to ::weekday[], a type that no longer exists — it
was replaced by text plus a CHECK constraint and the function was never
updated. apply_due_pending_changes had the same problem with
::relationship_type, which would have broken the nightly run.
PL/pgSQL resolves types in embedded statements at execution time, so
both functions were created without complaint and failed only in use.
2. Fourteen functions called auth.uid(). The application connects as a
role with no rights on the auth schema, so every write — hire,
transfer, promote, exit, notes, positions — failed with "permission
denied for schema auth". They now use app_current_user_id(), which is
where #23 was heading anyway. Its own fallback also caught only
"function missing" and now catches the privilege error too, so a call
without session context returns null instead of raising.
3. The audit line built a name as `payload->>'a' || ' ' || payload->>'b'`.
`||` binds tighter than `->>`, so Postgres reads
`payload ->> ('a' || ' ' || payload) ->> 'b'`. The ACL failure above
had aborted analysis before the parser ever reached it.
And the wizard collected an email, showed it in the summary, and dropped it:
the server action's signature had no such field. employees.email is NOT
NULL, so every hire that got past the three faults above would have failed
there. It is now passed through and required in step one, rather than
refused by the database at the end of step four.
Verified against the live database, each rolled back: a hire now creates the
employee, the assignment, the history entry and an audit line reading "Probe
Einstellung"; open positions drop from 13 to 10, and the three that
disappear are exactly the ones with a starter.
Migrations rewrite the affected functions in place rather than restating
them — retyping 165 lines of working PL/pgSQL to change two words is the
larger risk. Each one asserts the result afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| 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. |
|||
| 8d978981b0 |
SVNR validation, CI, and a dependency/security pass
Positions
- Removed the "Besetzen" action, the StaffInternallyModal behind it and the
now-unreachable staffPositionInternally server action: a position is filled
through the hire process, not from the positions list. Note that
transfer_employee has no position_id at all and never touched `positions`,
so with staff_position_internally out of the UI, hire_employee is the only
thing that closes a position — a transfer into an open one leaves it open.
The RPC itself is still in the database and still covered by its tests.
SVNR
- Austrian social security numbers are now validated: ten digits, weighted
check digit mod 11, and the TTMMJJ tail cross-checked against birth_date,
which is what catches a transposed date that a valid check digit would let
through. A serial whose weighted sum lands on 11 is rejected rather than
wrapped — those are never issued.
- Applies to Austrian locations only; the German/Czech/Slovenian equivalents
have their own formats and stay free-form.
- Enforced by a trigger, not inside hire_employee/change_employee_data, for
the same reason as the assignment history: both have been redefined by
half a dozen migrations. Only a *newly written* value is checked, so a
legacy number never blocks an unrelated transfer or address change.
- The seed drew a random four-digit prefix, so its check digit was right
only by chance and every seeded Austrian row would now be rejected;
it computes the check digit properly now.
Tech stack
- next 16.2.11 closes nine advisories against 16.2.10, including a
middleware/proxy bypass in App Router apps on Turbopack — proxy.ts is this
app's entry gate. RLS remains the real boundary, so the blast radius was a
blank page rather than data, but it is a patch-level fix. Also react
19.2.8, tailwind 4.3.3, lucide-react 1.26, supabase-js/ssr, postcss.
- CI runs lint, typecheck, schema/type drift, tests and build; a second job
replays every migration onto an empty database and runs the integration
suite against it, so a migration that cannot be replayed from scratch
fails here instead of during a restore.
- scripts/check-schema-types.mjs diffs the hand-written lib/supabase/types.ts
against the migrations. Reading the SQL rather than a live database keeps
Postgres out of the fast CI job. Verified in both directions.
- vitest now runs two projects: node for logic, jsdom for components. The
first component test covers the org chart expand control, which broke
earlier this session when elementsSelectable={false} made React Flow
compute pointer-events:none for the whole node; re-introducing that prop
fails three of these tests.
- Content-Security-Policy is emitted report-only. Enforcing a policy derived
from inspection rather than from violation reports risks blanking the app;
'unsafe-inline' on script-src is required until a nonce is threaded through
proxy.ts, which is a separate change.
- Fixed supabase/seed.ts, which this session's SVNR change had broken: the
extensionless "../lib/svnr" import does not resolve under Node's ESM
loader, so the seed failed at startup.
- engines pinned to node >=22 <25, tsconfig target ES2022, and the dead
test:e2e script removed (no Playwright is installed).
|
|||
| 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. |
|||
| 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`). |
|||
| 80cfaa1e04 | Phase 5: Positions und Bereiche page with create/staff position modals | |||
| f91a69147e |
Phase 3: Hire wizard, draft resume, and two real SQL bugfixes
- components/hire/: 4-step Hire Wizard (Person/Position/Vertrag/ Zusammenfassung) matching sec4.4, with a HireWizardProvider context so it can be opened both from the global "+ Neueinstellung" button and from a "Fortsetzen" link on a saved draft. - actions/hireDrafts.ts: save/delete hire_drafts (owner-scoped RLS already in place from Phase 1). Dashboard now shows the "Entwuerfe" card the Phase 1 plan deferred, since the wizard it depends on now exists. - lib/positions.ts: shared open-positions loader (position number, org breadcrumb, resolved manager name) used by both the wizard and (later) the Positions page. Two real bugs found via live testing and fixed in supabase/functions.sql: 1. hire_employee/rehire_employee: a two-branch CASE returning bare string literals defaults to `text`, not the target enum, so `status = case when ... then 'Geplant' else 'Aktiv' end` failed against the employment_status column. Fixed with an explicit ::employment_status cast on the whole CASE expression. 2. Postgres precedence gotcha: ->> and || sit at the *same* precedence tier and left-associate, so `payload->>'first_name' || ' ' || payload->>'last_name'` does not group the way it reads - it tries to apply ->> to an intermediate text value and fails with "operator does not exist: text ->> unknown". Fixed by parenthesizing every ->>'...' expression that participates in a || chain. Also fixed: hire_employee referenced v_position.title outside the branch that assigns v_position, raising "record not assigned" whenever a hire wasn't tied to a position_id; extracted a v_job_title variable instead. Verified live end-to-end: wizard search -> select position -> submit creates the employee, closes the position, and writes matching employee_history + audit_log rows atomically. |