194b6d8916c239d19701e448a10f63a2e3d9c70e
106 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| e8e675fd07 |
Turn the note filter around: yours by default, colleagues added
Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.
The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.
What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.
Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.
Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| 6903013548 | Link statt a-Element, und check deckt ab, was der CI prueft | |||
| eb25369d1d |
Let the truncated Anstehend list open
"… und 5 weitere Ereignisse in diesem Zeitraum" was a sentence to read. It is now a button: click it and the rest unfolds in place, click again and the card goes back to eight rows. Until now the only way to see the remaining entries was to narrow the period or the kinds — which changes the question rather than the answer. This happens in the browser, not through the URL, unlike the period and kind filters. Those go through the address because a longer period brings rows into play that were never loaded; here every entry in the period is already on the page and the eight was purely presentational. A round trip would mean waiting for data that is already there, plus a history entry for something nobody wants to go back to. The list moved into its own component so the state has somewhere to live. KIND_LABEL and the item type moved with it, since they only describe this list. The button only appears when there is something to unfold, and once open it offers the way back — otherwise the card stays long for the rest of the session because somebody looked once. Verified: 10 tests, five mutation-checked (ignoring the state, dropping the way back, showing the button with nothing to unfold, losing the singular, dropping aria-expanded each turn them red). 487 tests, typecheck and build clean. Not seen in a browser — login goes through the company account and the database is unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 99b1df9735 |
Choose whose notes reach your bell
The bell is a shared pile: every active HR person sees every open note, regardless of who wrote it. That was agreed and it stays the default — this narrows it, it never widens it. You can now untick colleagues whose notes you do not want to see. What gets stored is the *exceptions*, not the selection. The difference shows the day someone new joins HR: had the selection been stored, she would be invisible to everyone until each person ticked her, and nobody would notice her follow-ups piling up. This way she is visible from day one and hiding her is a deliberate act. Same reasoning that made notes a shared inbox in the first place — the silent gap is worse than a row too many. Own notes always come through: `note_mutes` rejects a self-reference, and the predicate says so again rather than depending on a check constraint staying put. Notes with no author come through too — hiding one because nobody knows who wrote it is exactly the loss this list exists to prevent. The rule lives in lib/notes.ts as one SQL expression because two places need it: the bell in the header and the "Anstehend" card on the dashboard. Two copies drift, and then the card counts something the bell does not show. No SQL function and no audit row, unlike anything that touches employee data — this is a personal display preference, and an audit trail recording every tick would make finding real changes harder. Same pattern as saved reports and hire drafts, and the owner policy on note_mutes means a row for someone else cannot be written even with invented values. The checkbox flips immediately and flips back if saving fails; the list gets clicked through several at a time and a round trip per tick feels like hesitation. Verified: 19 tests, five mutation-checked (or→and, dropping the own-notes clause, inverting `not exists`, inverting the default, and losing the email fallback each turn them red). Typecheck, lint, schema drift, 477 tests and the build are clean. Not seen in a browser: login goes through the company account and the database is unreachable — the migration is reviewed but has not been run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| c6cff9656e | Passwort-Anmeldung: Provider, Formulare, drei Zustaende der Shell | |||
| 405d708bc4 |
Sort from the column headers, all seven of them
The dropdown is gone; each column header is now a link that sorts by that column, with an arrow showing the direction. Clicking the column already sorted reverses it; clicking a different one starts ascending again — going from "Eintritt, newest first" to "Name" should give you names from A, not inherit the previous direction. Names sort by surname and then forename, as asked. Both parts reverse together: turning only the surname would give Z-A across surnames but A-Z within each one, which is visible immediately among the fifteen Aigner. Three of the seven columns are not on the employee row. Bereich and Team hang off the position, Standort off a lookup table, so they are fetched as correlated subqueries rather than joins. That is not a style preference: the same filter chain produces the page *and* the count, and a join onto position_assignments would double every person who has held more than one position over time — the line above the list would read 1,203 for 867 people. Bereich is the level below the company, so it needs to walk up from the unit. No recursion: org_unit_type has exactly four levels, so two hops up cover it. Everything sorts `nulls last`, otherwise reversing the direction floats every person without a position or location to the top. The expressions live in lib/employee-sort.ts rather than in the page so the generated SQL can be read in a test — the failure mode here is silent, the list still shows fifteen rows, just the wrong ones. Eighteen tests, and the rules are mutation-checked: dropping the forename, dropping the id tiebreaker, dropping `nulls last`, sorting the location by its uuid, and shortening the Bereich walk each turn them red. Not seen in a browser: login goes through the company account and the database is unreachable. Typecheck, lint, 458 tests and the build are clean. 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> |
|||
| 5c310c3a58 |
Let the employee list be sorted A-Z or Z-A
Sorting lives in the URL, not the browser. The list is built on the server and fetched a page at a time, so a client-side sort would only reorder the fifteen rows on screen — with 867 people that promises an alphabetical list and delivers something else on page 2. The sort key is the surname, because that is how the column reads: "Aigner, Manuel", and whoever looks for someone looks under A. Postgres runs with the Austrian collation, so Ö sorts with O rather than at the end of the alphabet. Only the surname reverses. The id stays ascending: it decides nothing except ties, and it exists to keep the order total across page boundaries. Reversing it too would still be deterministic but would flip the fourteen Winklers relative to each other for no reason anyone asked for. The select sits in the filter bar rather than in a clickable column header — a header would suggest it sorts what is on screen. Verified: compiled SQL is `order by last_name desc, id` for Z-A; the parse and direction rules are covered by tests that were mutation-checked (breaking each rule turns them red). Not verified in the browser — the login goes through the company account, and the Supabase instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 19e3170b00 |
Print a checklist without printing the application around it
The checklist gets a print view: one A4 page, header carrying personnel number, name, date of extract, entry date and position, the items in two flowing columns so twenty-five fit, and two signature lines at the foot. It prints the *state*, dated — not a blank form. The default filename in the save dialog comes from the document title, which is set to the agreed convention: 20260818_2884_Aigner-Manuel_Onboarding-Checklist Surname first, like everywhere else in the application, so a folder of these sorts by person and within a person by date. Umlauts are resolved rather than stripped: the naive route (NFKD, then every non-ASCII to a dash) turns "Müller" into "Mu-ller", because decomposition splits the umlaut and the diaeresis becomes the dash. "Weiß" needs its own rule — it has no decomposition and would otherwise vanish. The reported defect: the printout carried the application's own top bar — hamburger, bell, "Neueinstellung", sign-out. Those are controls; on paper they are decoration, and on a checklist filed in a personnel record, misleading. The rule now sits on AppShell rather than on this one page, so the org-chart print view — which had the same problem — gets it too, along with anything printed later. The shell's padding goes with it: the type area is set by @page on the print page itself, and the shell's would have been added on top. The browser's own header line (date, title, URL) is separate — that is a checkbox in the print dialog, not something CSS can reach. 440 tests pass, including 13 new ones pinning the filename convention. Not yet seen in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| f85731dde5 |
Give exits their own checklist, next to the entry one
The offboarding list was a checkbox fieldset inside the exit panel — four items, never sent anywhere. Nothing in terminateEmployee's payload carried them; ticking a box there recorded exactly nothing. It's replaced with the same kind of list the entries got: its own tab, appearing the moment an exit is recorded, with one item per row, a comment on each, and — unlike the fieldset — a record of who touched it and when. The eleven items come from the same printed sheet as the entry list. Nine are plain checkboxes. Two are text fields under "Vermerke": remaining vacation and the balance transferred for payout — the sheet names "Überleitung Salden für Auszahlung" twice, once as a task to do and once as the actual figure, and those are genuinely two different questions, kept as two items. Where the sheet still says "GKK" rather than today's "ÖGK", it's left as written — that's the name the process runs under internally, not a typo. No Show gets no list. Never having worked a single day, there's no IT access to revoke, no GKK registration to undo, no Dienstzettel to collect — an empty checklist there would be a label with nothing behind it. Both the tab and the auto-creation on exit check for this specifically, not just the "Ausgetreten" status that No Show shares with a real exit. Rehiring the same person hides the tab again — the data stays, since it happened, but a checklist for someone currently working has nothing to point at. The engine (what counts as done, how progress is computed) moved into lib/checklist.ts so onboarding and offboarding can't drift into two different ideas of "done" the way two independent copies eventually do; lib/onboarding.ts and lib/offboarding.ts bind it to their own item list, and the tab UI is a single ChecklistPanel bound the same way. Checked against the real database: a real exit creates all eleven items in the same transaction as the exit itself; a No Show creates none; rehiring flips the tab off while the old answers stay queryable. One false alarm during that check turned out to be the user's own clicks on a real employee's onboarding list, made in the browser while trying the earlier feature — left untouched, not test debris. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 82d07f0d95 |
Put the onboarding checklist where the file is
The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.
Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:
Haken — the normal case. The Meldezettel is there or it is not.
Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
not decoration: "nein" is a finding, "not asked yet" is not.
One checkbox cannot say both.
Text — shoe, shirt and trouser size. The value is the point;
ticked off it would be worthless.
Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.
Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.
The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.
A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.
Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| e30923e781 |
Give a position a cost centre
There was none anywhere in the model, so no personnel-cost figure could be produced at all, and an open position could not say whose budget it would charge — which is the first question asked about a vacancy. It hangs on the position, not on the person: the seat costs money even when nobody sits on it. That is exactly the vacancy case. And not on the org unit either, although it usually follows from one — a single seat can be charged elsewhere (project, shared function) without the unit moving. As its own dated assignment table rather than a column, because reassigning is an event with a date. Last year's costs have to stay where they were incurred; as a column, every change would silently rewrite every past report. Half-open [valid_from, valid_to), like position_assignments and om_positions — in SAP OM this is A011. 25 cost centres seeded from the org tree: one per company, division and department, with teams charging to their department, because a team is a span of control and not a budget. All 823 positions were assigned from their own start date, none left over. The number is the first five digits of the org number, so it can be traced rather than looked up. Reassignment refuses three things, each checked: the same cost centre again, a switch on the day the current one started (that period would never have been in force, and the range constraint says so), and a date before the position exists. Verified against the real data, which turned up a defect worth keeping: a position that starts in the future is charged only from its start, so asked about today it had no cost centre — and future positions are exactly what the vacancy list is for. It is now read at the position's own start date. Two audit entries from the probe could not be deleted through the application (the log has no delete policy — correctly), so I removed them with the admin connection. Still open, and the reason this is only the first of the three fields I proposed: location and planned FTE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 6957b95a97 |
Let the overview say what "upcoming" means
Sixty days and all three kinds was a guess, and it was the only one on offer. Payroll cares about next month; the person filling a vacancy cares about entries and nothing else. The card now takes a period and a set of kinds. The choice lives in the address rather than in the browser, because it has to: the page is built on the server, and ninety days pulls in rows that were never loaded at sixty. Filtering client-side would silently cap the answer at whatever the first query happened to fetch. It also means a filtered overview can be sent to someone and opened again the same way. Deselecting every kind returns to all of them. An empty card is not an answer to a question nobody asked, and the way back would otherwise be one click further than the way in. The default period and the full set are absent from the URL instead of written into it, so a shared link carries only what was actually chosen. Anything the address cannot be trusted to hold is rejected: an unknown period falls back to sixty rather than reaching the query, which would otherwise be an invitation to ask for ten years of rows through a link. Eight rows still, with a count of what did not fit underneath — this is an overview, and the employee list is where lists belong. Not verified in a browser: the built-in preview has no company sign-in, so the page redirects to the login before it renders. Types, lint and 386 tests pass, and the filter's behaviour is covered directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 16216c5537 |
Let a planned absence be called off
The old refusal read: "Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden." There was no such way. The row sat in the file, the scheduled change kept running toward its date, and nothing could stop either one. That is not hypothetical. One person went absent in July, came back in August, and still has a second return booked for the first of September — recorded while they were already working again. The guard added yesterday stops a third from being written; it does not remove the one that exists. Absences are called off whole, not field by field. For a planned contract change the scheduled payload gets the affected fields lifted out of it and runs on with the rest; an absence has no fields in that map, and half an absence is not a thing anyone means. So the whole scheduled change is cancelled, and what it had already noted on the person goes with it: the date they were to be away from, the date they were to come back on. Left behind, the profile would show an absence with no event behind it. If the absence is still running, the return date planned when it began applies again. The link between the row and the scheduled change had to exist first — start_karenz and record_karenz_return now record it. Existing rows get it backfilled, but only where one running change of that kind falls on that person and that day. Where two would match, the row keeps refusing: guessing which process to cancel is worse than refusing to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 861c47b757 |
Correct an entry date, and stop returns without an absence
Three things, all from the same screenshot. The entry date can now be corrected. The Eintritt entry gets an edit button — date only, no delete, because it is the start of the timeline and a person without one has no beginning. Unlike every other entry it needs no recorded before-values: the old date is on the employee row, so this works on rows written long before any of this existed, which is exactly the case that matters. What hangs off that date is checked: no other event may precede it, exit and absence start may not fall before it, and the first position assignment moves with it — left behind it would leave days of employment with no post, or a post with nobody in it. Someone already working cannot be given a future entry date either; without that check a person who has been here for years could be turned into a planned entry, and the status derivation would agree. That last rule came out of the rehearsal finding a hole: my first probe picked a person with no other history rows, so the "nothing may precede it" check had nothing to compare against and a date in 2099 sailed through. Second, the screenshot showed two returns from one absence, and the data confirmed it: one person with two Rückkehr entries and a third still scheduled, recorded while they were long since active. record_karenz_ return never checked that there was an absence to return from. Now it does, and it refuses a second scheduled return — which would have silently overwritten the first on its effective date. Third, the history is filterable: upcoming versus done, a date range, and the event types that actually occur in that file. The count of upcoming items shows without filtering, because "what is coming" is the usual reason to open the tab at all. Still not deletable: Versetzung, Beförderung, Austritt, Wiedereintritt, Reorganisation. Undoing those means restoring position assignments, and that deserves its own step rather than being tacked onto this one. 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> |
|||
| 384bdb4fb3 |
Let an absence be taken back, in the right order
A long-term absence recorded by mistake could only be undone by booking a second event on top of it — leaving two entries in the file, the first of which never happened. Karenz and Rückkehr can now be deleted and corrected like the other entries. For that to restore anything, the two operations first had to start recording what they overwrote. start_karenz and record_karenz_return now keep before/after the way change_employee_data does: status, kind of absence, start, planned return — and for a return also employment type, hours and the part-time variant. Without that there is nothing to revert to, only a sentence. The ordering rule HR asked for is enforced in the database, not just in the UI: an absence cannot be deleted while a later return exists. A return standing on its own would be a return from nothing, and the person's status would derive from an entry whose starting point had been deleted. Delete the return first and the absence frees up. Rehearsed end to end on real data: absence recorded, return recorded on reduced hours; deleting the absence refused; deleting the return put the person back on Karenz with the original hours and the part-time variant cleared; deleting the absence then put them back to Aktiv with no trace. Rows written before today carry no before/after and stay untouchable, with the reason they already gave. Planned absences are refused too — they have their own operation, and their fields have no place in a pending payload, which is why app_feld_karte carries a null group for them rather than a plausible-looking wrong one. 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> |
|||
| e6554e7982 |
Move the part-time arrangements out of the absence list
Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit and Wiedereingliederungsteilzeit were offered as kinds of long-term absence. Recorded that way, the person counted as absent: they dropped out of headcount, their reporting line fell to a stand-in, and reports stopped counting them — while they were in the building every week, just for fewer hours. A part-time arrangement is not an absence; it is a change of hours. They now sit where they belong. Wiedereingliederungs- and Elternteilzeit appear when recording a return from absence, as the reason someone comes back on reduced hours — both typically begin exactly when the absence ends. Bildungs- and Pflegeteilzeit appear under "Daten ändern" beside the hours, next to the ordinary contractual change. The reason is recorded with the change, not as a state on the person. A state would have to be maintained, and nobody goes back to note when a Bildungsteilzeit ended; a field that quietly goes stale is worse than none. In the history it stands next to the value it explains, and stays readable for good. The check constraint on absence_type is deliberately untouched. Three people carry the old values right now — two Pflegeteilzeit, one Wiedereingliederungsteilzeit. Forbidding them would make existing rows illegal. They are gone from the list of choices; the history stays readable. Those three are worth revisiting, but that is a data decision, not a code one. Rehearsed against real data: an hours change with a reason and one without, a reduced return with a reason and an unchanged one — checked by reading both new history rows rather than "the latest", since now() stands still inside a transaction and made an earlier probe report a false negative. I also overwrote tests/unit/absence.test.ts instead of extending it. The original cases are restored; the diff is 49 added lines and 3 changed. 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> |
|||
| 0144267a59 |
Record people who never turned up
Someone hired who then does not start needs an exit reason of its own, and until now the case could not be recorded at all. Terminating on the entry date failed on chk_assignment_range: the assignment was closed with valid_to = valid_from, and an empty interval is forbidden there. Moving the exit to the next day would have claimed a day of employment that never happened — headcount, tenure, every as-of report. "No Show" is now an exit reason, and it behaves differently in three ways. The exit date is always the entry date, whatever the caller passed. That is what makes "never active" true rather than asserted: a person counts as employed when their exit date is *after* the reporting date, and here it never is. The status derivation needed no change at all — it already says Geplant before the entry date and Ausgetreten from it on. The position assignment is deleted rather than closed. The post was never filled, it goes back to being open, and nothing records a holder who never held it. The status column goes to Ausgetreten immediately, even for an entry still in the future. Otherwise it would read Geplant forever — nothing runs later to correct it. A constraint holds the first of those regardless of the path in, including the import: exit_reason is distinct from 'No Show' or exit_date = entry_date. "is distinct from" rather than "<>" so an empty reason does not evaluate to null and slip through — the same three- valued trap that let an earlier check pass the case it was written to stop. The dialog locks the date field when No Show is picked and says why, so nobody types a date that would then be silently overridden. The offboarding checklist is hidden: nothing was ever handed out. Rehearsed against real data — a planned entry with a 2099 date passed in, which came back as the entry date; derived status across three reporting dates never Aktiv; a direct write with a mismatched date refused; and an ordinary termination unchanged. 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> |
|||
| a3fac47f47 |
Give each characteristic its own line, and name the car
The contract sheet had one field, "Merkmale", holding whatever applied, comma-separated — and a dash when nothing did. Two problems in one row. A dash cannot distinguish "has no company car" from "nobody ever answered the question", and the entry read "Dienstwagen" without saying which kind, which is the thing worth knowing since electric vehicles are tracked separately. Betriebsrat, Dienstwagen, laterale Führung and C-Level are now four lines like every other line on the sheet, each with Ja or Nein. The company car shows its drivetrain instead: E-KFZ or Verbrenner. That label existed in three places — the dropdown, the hire summary and now here. It lives in lib/dienstwagen.ts, so the same car cannot end up named differently depending on which screen you are looking at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 08d2740690 |
Let planned changes be taken back and corrected too
Deleting and correcting a history entry stopped at the present: anything not yet effective stayed put. That was not a principle, it was a missing link. A planned change lives as a payload in pending_org_changes, and nothing tied it to the history row — only a person and a date, and the data already holds an Eintritt and a Vertragsänderung sharing one. So employee_history now carries pending_id, set by change_employee_data when it schedules something. One planned change can carry two history rows: Stammdaten and Vertrag are kept apart but scheduled together. Taking one back therefore strips only that group's fields from the payload, and cancels the operation only when nothing is left. Correcting one rewrites its group and the effective date, and touches no employee data — the change has not happened yet. An entry stays on its side of the present. Pulling a planned change into today, or pushing an effective one into the future, would mean adjusting the employee record and the pending payload in opposite directions; that is what the real operations are for. Existing rows were linked where exactly one running operation matched the person and date and no other row had claimed it. All five of them matched. Anything ambiguous would have kept the old refusal, which now says the actual reason. The edit dialog surfaced a bug in useDialogFocus that predates it: the effect depended on the identity of onClose, which almost every caller rebuilds on render, so it re-ran after each keystroke and its cleanup pulled focus back to whatever opened the dialog. Any dialog with a text field would have accepted one character. It never showed because until now no dialog kept its own state next to its own onClose. Rehearsed against real data: a two-row planned change corrected, one row taken back with the operation continuing on the rest, the second taken back with the operation cancelled, and both refusals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 6297288c13 |
Let an entry be taken back, along with what it did
HR can now delete a history entry, but only where deleting one is an honest thing to do — and deleting it also undoes it. The rule they asked for is the interesting part: the last valid change wins. Deleting an entry walks its fields one at a time. If a later entry touched the same field, the current value stays — that later change is the one in force. Otherwise the field goes back to what the deleted entry recorded as its "before". So the middle of three entries can be removed without an old value overwriting a newer one. Four kinds of entry refuse to be deleted, each saying why in the place the button would have been. Eintritt anchors the timeline. Transfers, promotions, absences and exits moved positions and status — they have proper operations for that, and guessing backwards is how you corrupt an org chart. Anything not yet effective hangs off a planned change, and that link is not trustworthy: there is no key between a history row and its pending row, only a person and a date, and the data already has an Eintritt and a Vertragsänderung sharing one. Matching on the date would eventually cancel a change nobody meant. And entries from before the history carried values have nothing to fall back to. Confirmation is not "are you sure" — that question gets a reflex yes by the third time. The dialog says what will be different afterwards: which field goes back to which value, and which one stays because something later claimed it. employee_history keeps its append-only policies; delete_history_entry is SECURITY DEFINER and checks the permission itself in its first line. The audit log keeps the deletion with the values that were removed, and the audit log genuinely cannot be edited. The rule lives twice — in SQL and in lib/history.ts. The database is the authority; the copy exists so the UI can hide a button that would fail and print the reason instead. Rehearsed against real data in a rolled-back transaction first: the later change held, the untouched field reverted, all four refusals fired. Also corrected in the data catalogue: I had written that require_hr_admin was called by nothing. It guards all sixteen mutating functions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 5f50cb97f3 |
Let the history say what an address was before
HR reported it from testing: change someone's address and their history shows "Geänderte Felder: Adresse, Ort" — the new address is on the Stammdaten tab, the old one is nowhere. It was recorded, but only in the audit log, which is a different page sorted by time and actor rather than by person. So you had to already know what you were looking for to find out whether an address had ever changed, let alone what it used to be. The field-by-field diff was being built anyway and written to the audit log. employee_history now carries the same list, and the person's history renders it as an expandable Feld / Vorher / Nachher table — the same table the audit log uses, lifted into a shared component so the two views don't drift into reading differently. It expands with <details>, so the values are in the page: findable with Ctrl+F, present when printed, no script involved. The duplication with audit_log is deliberate. A person's history should be readable on its own, including after the log is eventually thinned by a retention rule. Rows written before today stay without values. They could only be reconstructed from the audit log, and the link is not reliable — no key, only a timestamp and a person. Honestly empty beats plausibly wrong. The migration was generated from the live function definition rather than retyped, and the diff is four lines: two column lists, two value lists. It carries a self-check that raises if either insert failed to pick up the new column, and it was rehearsed inside a rolled-back transaction against real data first — the probe confirmed the old street name lands in the history row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 00df973824 |
Stop the print preview measuring itself into a freeze
Opening the org chart PDF preview locked up the browser tab. The measurement that fits each sheet to the page fed itself: the effect listed onFaktor in its dependencies, and onFaktor was an arrow function created fresh on every render, so the effect re-ran after every render. It measured, reported the scale, and the report called setState with a newly built object every time — new object, so React saw a change, re-rendered, and the effect ran again. Measure, render, measure, until React gave up with "Maximum update depth exceeded". Two changes, and the mutation test says either one closes the loop on its own: the callback now lives in a ref so the effect depends only on the sheet identity, and the reducer returns the previous state unchanged when the scale has not moved. Both are worth keeping — the ref stops the effect from re-running, the guard stops pointless renders. This shipped broken, and the reason it shipped is in the test file now. Every element in jsdom is zero pixels, so the measurement bailed out on its first line and the feedback never started; nine tests covering the selection, the page count and the hierarchy all passed against a component that froze on contact with a real browser. The new test gives the elements a size, and fails with the exact error a user hits. 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> |
|||
| f14f1cb8df |
Keep the org chart inside the page it is printed on
Six divisions, each with its departments beside it, ran off the edge of the sheet. The cause was structural, not cosmetic: every level spread horizontally, so width multiplied with depth. Six divisions times three departments is eighteen boxes across a landscape A4 — about two millimetres each, if they had fitted at all, which they did not. They overlapped and were clipped at the margin. Now only one level spreads sideways. The divisions stand in a row and everything below them hangs lengthwise off a vertical line, so width is the number of divisions and nothing else. Depth costs height instead, and on a landscape page height is what there is to spare. What still overhangs is scaled down as a whole. The sheet in the preview now carries the print area's exact dimensions rather than growing with its contents, so the fit is measured against the real page: what you see is what the printer gets. If a sheet has to shrink below 55% to fit, it says so and points at A3, instead of quietly producing something nobody can read. With names switched on, each department gets its own sheet — a whole division with every name was never going to be legible on one page — and long name lists set in two columns so the box grows sideways rather than pushing the scale down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 3151f32404 |
Print the org chart as an org chart, and ask first what to print
The chart on screen is an infinite canvas: you zoom in, drag around, and look at one corner at a time. Paper has none of that. Printing the canvas means scaling 850 people onto one sheet, which yields boxes two millimetres wide — technically the whole company, practically nothing. So the print view is rebuilt rather than shrunk, and it does two things the canvas cannot. It asks before it prints. Depth (bereiche, abteilungen, teams, or teams with every name) and which divisions, each one selectable. Whoever needs Produktion for a meeting gets two sheets instead of forty, and the page count is on the button before anything reaches the printer. And it draws the hierarchy as a hierarchy: boxes joined by connecting lines, not a column of cards. Superior and subordinate are the entire point of an org chart; a tidy list of the same units simply does not say it. The lines come from borders on pseudo-elements, so the PDF keeps them as vectors and they stay sharp when someone zooms in. Header shading gets weaker with each level down, which survives the black-and- white printer that most of these end up on. Overview sheet first, then one sheet per selected division, each carrying its own heading and headcount so page seven is still readable on its own. A4 or A3, landscape. 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> |
|||
| 4eba557121 |
Show every field the edit dialog can change
The master-data tab summarised where the edit dialog itemises. Titles were
collapsed into one line, street, postcode and town were fused into a single
"Adresse", and first and last name appeared only in the page header — so
checking a value meant opening the change dialog to see it, which puts you
inside a form when you only wanted to look.
The tab now mirrors the dialog's "Person" section field for field and in the
same order, personnel number included.
Two deliberate departures from a literal mirror:
- Standort sits at the end rather than between Adresse and Land. It is the
workplace, not part of the person's address, and next to the postal
fields it reads as though it were.
- The emergency contact keeps the separate block it got earlier today,
with its phone number as a tel: link. In an emergency someone reaches
for it in a hurry; it should not be one cell among fourteen.
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>
|
|||
| f28fd2da60 |
Let a rehire choose the position, and make it work at all
Clicking "Wiedereinstellen" could never succeed. rehire_employee has always
demanded a position and refuses without one, but the panel offered only a
date and sent only a date — so every rehire ended on an error the dialog
gave no way to fix.
The panel now picks from the open positions, the same list and layout the
transfer panel uses, and warns before submitting when the date falls outside
the chosen position's validity. The old position is deliberately not a
silent default: it may since have been filled, ended, or gone.
Behind that sat a second fault, hidden by the first: the status assignment
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end
is text, and the column is employment_status. Postgres refuses that outright,
so the function would have failed even with a position. It surfaced only once
the earlier check stopped firing — the same pattern as hire_employee this
morning, where three faults sat in a queue.
rehire_employee also placed people without checking anything. It now applies
the rule from 20260810100000: the date must lie in the position's validity,
and no assignment may still stand. A rehire could otherwise land on an
occupied position and be caught by the partial index, with a message that
explains nothing.
My first verification of the cast was wrong and passed a broken state:
plpgsql converts silently when assigning to a variable, so the probe proved
nothing. Redone as an UPDATE against a column, which is the case that fails.
Verified end to end against the live database, rolled back: Stefan Egger
returns as Aktiv on a free position, with the assignment and the
Wiedereintritt entry. Without a position, on an occupied one, and on one not
yet valid, it is refused — each with its own message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| a87c688c0a |
Show positions that do not exist yet, and let them be corrected
Two gaps in the positions view, both reported from use.
A position dated into the future was invisible. loadOpenPositions required
valid_from <= today, so a position decided now and effective at the quarter
boundary appeared nowhere until the day it began. The database already held
one — 60000824 "Neue Position", effective 01.09. — created through the
application and shown on no screen since.
Future positions now have their own section rather than joining the vacancy
list. They are a different statement: "nobody is here" and "this does not
exist yet" should not be counted together, and a position starting 01.10.
read as a vacancy nobody was filling.
Positions could only be created and deleted. Fixing a typo in the job title
meant deleting and recreating — with a new position number, which appears in
job postings, budgets and audit entries, and whose trail then breaks.
update_position keeps the number and records old and new values per field,
using the audit detail added earlier today.
Three things it refuses, as guards rather than remarks:
- Moving an occupied position to another unit. That is a transfer, with
history and reporting line, and belongs to the person — otherwise
someone changes department silently.
- Ending an occupied position, which would leave an assignment without
one.
- A second chief position in a unit, or an end before the start.
Verified against the live database, all rolled back: each guard fires with
its own message, the permitted edits go through, the audit entry carries the
changed fields. Open positions stay at 9 and the future one now appears in
its own section.
ESLint caught me priming the dialog's fields from an effect. Replaced by a
key on the component, so React rebuilds it per position and the fields
initialise from props — which also removes the flash of the previous
position's values on second open.
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>
|
|||
| 1271cef879 |
Record what a change was, not only which field it touched
The audit log said "Adresse, wirksam ab 30.07.2026". That names the field
and hides the answer: what did it say before? For a personnel record that is
the question the log exists to answer.
Both values are in hand at the moment of the change — v_old holds the row as
it was, the payload holds what is being written. change_employee_data
already compared them to decide whether to mention the field at all, then
dropped them. It now keeps them in audit_log.changes as
[{feld, vorher, nachher}], and derives the old one-line text from the same
array so existing views are unaffected.
Clicking a row opens the detail. Fields with no previous value read "leer"
rather than showing an empty cell, because "was not set" is itself a
statement.
Two honest limits, both stated in the panel rather than left to look like a
bug:
- Existing entries cannot be enriched. The values were never captured;
there is nothing to recover.
- Hire, exit and import record no individual fields, so they show none.
The rewritten function also drops auth.uid() for app_current_user_id(),
which works on either system — one of the last few call sites before #23.
Caught while writing this: my scripted edit of types.ts silently did nothing
and my own check reported success, because the pattern matched
pending_org_changes. Redone with the editor. That is the second time a
regex-driven edit has lied about its result in this project.
Not verified end to end: the migration needs privileges I no longer hold
after the database password was rotated. Until it is applied the audit page
will not load, since it selects a column that does not exist yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| 3926f1bb80 |
Load a whole organisation from a file, or none of it
Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.
Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.
Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.
Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:
- weekly_hours is bound to employment type by a CHECK constraint: full time
is exactly 38.5. The import reached the insert and was rolled back. Now
it is a finding with a row number.
- Titles are restricted to a fixed list by another CHECK. Same treatment.
- setval() needs UPDATE on the sequence, which `usage, select` does not
grant. Migration 20260803120000 adds it; until it is applied, an import
containing people will fail at the last step and take itself back.
I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.
Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| 2cce101c4b |
Sign in with Entra ID, and give the login screen something to look at
Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die
Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb
ist der Eingriff klein: auth.uid() liefert weiterhin eine UUID, profiles.id
trägt weiterhin role und is_active, und damit bleiben is_hr_user() und alle
58 RLS-Policies unverändert gültig. Die Sicherheitsgrenze wandert nicht in
den Anwendungscode.
Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem
Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten
Zugriff, Sperrung beim Austritt.
Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung
tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach
/login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt
nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route,
sondern profiles.role/is_active und darunter die Policies.
Zwei Werkzeuge für die Umstellung:
- relink-profile.ts hängt eine bestehende profiles-Zeile auf die
Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben
Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das
zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man
sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst
stünde in der Historie eine Kennung ohne Konto dahinter.
- entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat.
Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der
Anspruch heisst und aussieht, und das unterscheidet sich je nach
Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut,
sonst wäre er geraten.
Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities.
identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete
Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich
jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md.
Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine
Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die
Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht
mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser
Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit
grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man
wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung,
die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten.
Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px,
Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px.
Typecheck, Lint, Build und 182 Tests sind grün.
|
|||
| 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.
|
|||
| 2776c33d08 |
Roll reporting up past an absent manager, and say so on both sides
While somebody is on a long-term absence their reports report to the next
management level, and it keeps rolling up until it reaches somebody present.
Derived at read time in lib/acting-manager.ts rather than written to
employees.manager_id: the absent person stays formally in charge, so the
stand-in has to be visible as a stand-in rather than quietly replacing them.
Both ids therefore travel to the UI, and both sides carry a badge — the
absent person ("Abwesend · Vertretung: X") and anyone now reporting
elsewhere ("Vertretung für Y").
Three cases the walk has to survive, all covered by tests:
- Several absent levels in a row — it keeps climbing, and still names the
*recorded* manager as the one being covered for, not the level skipped.
- Everyone above absent — it stops and keeps the recorded manager. Re-rooting
a team to the top of the chart would distort more than showing an absent
manager whose absence is labelled anyway.
- A manager_id cycle, which nothing in the schema forbids.
An absent lead needs no separate deputy field: their stand-in is simply
their own acting manager, the same one their reports moved to.
|
|||
| 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. |
|||
| 37bb107cd4 |
Visual pass, clickable KPI tiles, and one consistent definition of status
Visual - `--radius: 8px` in @theme collapsed Tailwind v4's whole radius scale onto a single value: `rounded` and `rounded-lg` both measured 8px, so a chip, an input and a card could not be told apart. Named steps restore the gradation (6 / 8 / 12px, measured in the browser). - Cards were a 1px border and nothing else. Added warm, brand-tinted elevation tokens — a neutral black shadow over the pink surface reads as dirt — in three steps for cards, dropdowns and overlays, collected behind components/ui/Card.tsx so the 26 hand-copied card class chains have one definition. - KPI tiles lead with the number and carry a tone accent; tables got denser rows, subtle row rules (the full border strength made 800 rows read as a grid), tabular figures in numeric columns and a brand-tinted hover. KPI tiles now link to the view that shows what they count. Making those links honest surfaced two reasons the numbers did not agree with their destinations: - The dashboard read `employees.status`, while every report derives status from entry/exit/karenz dates. A hire whose start date had passed before the cron ran was counted differently on the two pages. The dashboard now uses the same derivation — and one query instead of five. - Eintritte/Austritte counted `entry_date`/`exit_date` while the linked report counts `employee_history`; rehire_employee sets entry_date but logs the event as 'Wiedereintritt', so rehires were missing from the target. Both now count history events. - The employee list filtered on the status column, so it disagreed too. It now filters on derived status in SQL (lib/employee-status-filter.ts). That restates deriveStatusAsOf a second time, in a second language, so an integration test runs both over the full roster and requires identical id sets — drift here is otherwise invisible. Status semantics, per the domain correction: "aktiv" means status Aktiv alone. Karenz is employed but not active, and has its own tile. The active headcount, FTE (Karenz contributes no capacity) and the division bars all follow that; the bars are labelled "Aktive nach Bereich" rather than "Headcount" to say so. The employee filter still offers the combination, named after the two statuses it selects instead of calling the pair active. DEFAULT_STATUSES in lib/reports.ts is deliberately left at Aktiv + Karenz: it governs what the Berichte page shows without an explicit status filter, and therefore what already-saved reports and exports mean. |
|||
| 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. |
|||
| 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`). |
|||
| f7e5cd6e6e | Fix Bericht speichern: window.prompt() is unsupported in this browser environment, replaced with a proper Modal dialog | |||
| 131ca7ece7 |
Daten aendern: effective date, searchable UN country pickers, 2 more bugfixes
Feature requests from live use:
- "Daten aendern" was missing a "Wirksam ab" field (unlike Versetzen/
Befoerdern/Karenz, which all have one) - every change was silently
logged with today's date. Added the field, threaded through
change_employee_data (defaults to today if omitted).
- Staatsbuergerschaft and Wohnland now use a searchable picker
(components/ui/CountryPicker) over the full 193-country UN member
state list (lib/countries.ts) instead of the original ~9/5-value
picklists. Dropped the now-too-narrow CHECK constraints
(supabase/schema_2.sql) since the app is the source of truth for
valid values, same approach used elsewhere for large open-ended
pickers.
Two more real bugs found via live testing of the above (both in
change_employee_data, supabase/functions.sql + functions_4.sql):
1. `text[] || 'literal'` is ambiguous in Postgres - it can resolve to
the array||array overload and try to parse the plain word as array
syntax ('{...}'), failing with "malformed array literal". Hit on
every single field-diff line the moment a user actually changed
something (Staatsbuergerschaft first, then Beschaeftigungsausmass
confirmed the same root cause). Fixed everywhere by switching to the
unambiguous array_append() function.
2. The contract_end_date diff-check cast an empty string straight to
date ("invalid input syntax for type date: ''") instead of using the
same nullif(...,'')::date guard the UPDATE line below it already had.
Verified live end-to-end after both fixes: changed Staatsbuergerschaft
to Brasilien with a backdated effective date, save succeeded, Stammdaten
tab reflects it, and employee_history got the correct event_date
("2026-07-01") and description ("Geänderte Felder: Staatsbürgerschaft,
wirksam ab 2026-07-01"). Reverted the test employee's data back
afterward; seed data is clean again.
|