e958bb5c6bd8ffff0cdbf05a203e035d04600742
45 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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> |
|||
| 521e4ecf00 |
Show the Offboarding tab from the day the exit is recorded, not the day it takes effect
The tab was keyed off employee.status === "Ausgetreten", and that stays wrong for weeks: terminate_employee writes exit_date immediately no matter how far out the date is, but only flips status once the date itself arrives. A termination entered today for four weeks out left the tab invisible for the entire notice period — exactly the stretch in which IT access, hardware and deregistration actually get worked through, and exactly where the checklist was supposed to live "next to Onboarding," per the report that caught this. The rule now reads exit_date instead: not null, and not a No Show (which sets exit_date too, to the entry day, but never worked a day and gets no checklist). rehire_employee resets both exit_date and exit_reason to null, so a rehired person's tab still disappears the same way it did before — nothing about that case changed, only the signal the check reads. Verified against the real database with the exact shape from the report: a termination dated 30 days out. Status stays "Aktiv", the checklist exists immediately, and the tab's own predicate says yes. 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> |
|||
| 3a4f44318c |
Derive the filter test's list from the registry it tests
Adding the follow-up kind turned one of these tests green-for-the-wrong- reason and one red: both had the three kinds written out by hand, so "all of them are selected" no longer meant what the name said. That is the failure mode a hand-copied list has — it does not break loudly, it drifts. The list now comes from ANSTEHEND_ARTEN, and the two cases that depend on completeness build their input from it. I committed the previous change with this test red. That was wrong; it should have blocked the commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 10f8f1f2d5 |
Keep a note on the overview until somebody ticks it off
A note with a follow-up date is a task, and the overview is where tasks are looked for. Until now it lived only in the employee file, which is the one place you go when you already know who you are looking for. Follow-ups behave differently from everything else on that card, and the difference is the point: an entry on Monday is over on Tuesday, an unfinished task is not. So there is no lower bound on the date — what was due and never ticked off stays, marked overdue in red, sorted to the top because it is sorted by date. A task that drops out of the list by itself is a forgotten task. Only "Erledigt" removes it. A note without a follow-up date never appears: it is a record, not a task. Checked against the live database — an overdue one and an upcoming one appear, one without a date and one beyond the chosen period do not, and ticking the overdue one off removes exactly it. The probe notes were deleted again. 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> |
|||
| 33d053ce75 |
Match search words at the start of a word, not anywhere inside one
Searching "Winkler H" returned all seven Winklers instead of the one Hannah. Each word was matched as a substring, so "H" hit T-h-omas, Kat-h-arina and CNC-Dre-h-er:in — every row. The shorter the input, the more useless the result, and an initial is the shortest input anyone would type. A word now has to match at the start of a word: either the haystack begins with it, or a space does. The haystack is first name, last name and job title joined, with hyphens, slashes, colons and dots flattened to spaces, so "dreher" still finds CNC-Dreher:in and "cnc" still finds both the Dreher and the Fräser. Checked against the live data before and after: "winkler h" now returns Hannah Winkler alone, "h winkler" the same in either order, "winkler kat" the two Katharinas, "dreher" the twelve CNC-Dreher. The trigram index on the concatenated name no longer applies, which is the price. At under nine hundred rows the scan is a few milliseconds; an index on the same expression brings it back when that stops being true. LIKE's own wildcards are escaped now — typing "100%" searched for everything before. 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> |
|||
| 27e0e8a8ce |
Ask for someone's organisation on a day they actually have one
An employee starting 01.09. showed "Keine Führungskraft (Geschäftsführung)" although their team has one — Josef Bauer, on chief position 60000752. The reporting line was requested as of today, and today that person holds no assignment, so om_reporting_lines() returned no row at all. The database function was right; the caller asked the wrong question. What made it look like a data problem rather than a date problem: the header did show the unit and the position, because pickPlacements() falls back to the next best assignment when none is current. Two notions of where someone sits — one forgiving, one strict — sitting next to each other on the same page. orgAsOf() pulls the date into the employment: the first day for someone not yet started, the last for someone who has left, today otherwise. Exit dates are exclusive throughout the model, so the last working day is the day before. Anyone already gone had the same defect for the same reason, which is why the rule covers both ends rather than special-casing the case that was reported. Verified against the live database: as of today no row, as of 2026-09-01 the manager is Josef Bauer. Six unit tests over the boundaries, checked by mutation — remove the future-entry branch and one fails. Open positions still resolve as of today: they belong to the organisation, not to the person whose file is open. 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>
|
|||
| 16b37244c8 |
Read an import file without guessing what it means
First half of the mass import: a file becomes named sheets with typed rows,
and every rule that could reject a row is stated in one place.
Nothing here touches a database. The parser turns bytes into sheets, the
schema says which columns exist, and validation reports findings — the
existing state is passed in as a parameter. That is what makes 36 tests
possible without a connection, and the rules are the part worth testing.
Three decisions where the easy choice would have been silent corruption:
- A two-digit year is refused. "15.08.68" is 1968 as a birth date and 2068
as a contract end, and any rule invented here creates people not yet
born.
- "31.02.2026" is refused. Date turns it into March 3rd without complaint.
- An unrecognised value in a yes/no column is an error, not "no". Read the
other way, a typo in "Betriebsrat" quietly removes someone's dismissal
protection.
CSV is parsed rather than split. German Excel writes semicolons because the
comma is the decimal separator, so the delimiter is sniffed from the header;
a semicolon inside a quoted address would otherwise shift every following
column and import the row plausibly wrong. Quoted newlines, doubled quotes
and the byte-order mark Excel prepends are all handled — the last one makes
the first column read as "?Personalnummer", which is invisible in an editor.
Validation collects every finding instead of stopping at the first. With 800
rows that is the difference between correcting once and uploading eight
hundred times.
One rule earns its place from experience: a history event dated before the
entry it belongs to is refused here, with a row number, because the database
refuses it too — mid-insert, without one.
My own slip, caught by the type checker: `a ?? b ? c : d` does not mean what
it looks like; ?? binds tighter than the conditional.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| 4d8e3f7154 |
Restore the shapes the application was written against
The app runs against a real database for the first time since the port, and two things were broken. Both were invisible to typecheck, lint, 192 tests and the build. Sign-in looped. Auth.js created the session and app_upsert_user() adopted the existing profiles id correctly — the row was in app_users, right id and all — but the proxy builds its own Auth.js instance from lib/auth/config.ts alone, and the session callback that copies token.uid onto session.user.id lived in auth.ts. So the proxy saw a session without an id, treated every signed-in user as signed out, and sent them back to /login. Click, flash, login page: from the outside it looked like the button did nothing. The callback moves to the config both instances share. auth.ts now spreads the base callbacks instead of replacing them, which is the mistake that would reintroduce this. The proxy test did not catch it because its fixture hands the handler a session that already has user.id — it tested the routing, not the shape Auth.js actually produces. Then the dashboard crashed on a.date.localeCompare. PostgREST returned JSON: a `date` arrived as "2026-08-03", a `numeric` as a number, and that is what lib/supabase/types.ts declares and what every sort, every date comparison and every status derivation assumes. The pg driver does the opposite — Date object and string respectively. The declarations stayed true to what the code believes; only the runtime value changed, which is why nothing flagged it. The driver is configured back to the declared shapes in lib/db/pool.ts, rather than rewriting 49 call sites. That also removes a timezone hazard: `date` is a calendar day, and as a Date object it acquires midnight in the server's zone — a birth date would shift by a day in Austria, always. The same class of bug as in the seed. int8 stays a string on purpose: it only comes from count() and is read through Number() everywhere; parsed as a number it would quietly lose precision past 2^53. A missing sign-in error now reaches the server log. Auth.js was failing silently — a 302 back to /login and nothing to read. That was its own defect, and it is the reason the first diagnosis took as long as it did. Verified against the live database: all six pages render, 797 active of 852 records, 744.4 FTE, and a detail page shows birth date 15.08.1968 against SV number 7960 150868 — the digits agree, so no day has shifted. Both new tests were checked by mutation: remove the fix and they fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 2ba9b37aa7 |
Hand the front door to Entra, and keep the keys out of the build
Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.
lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.
That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.
The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.
Two things only came out by running it:
- `export const proxy = auth(…)` is not a function declaration, so
Next.js never found it and every request 404'd. `next build` reported
success and listed the proxy. In the function config form auth() also
returns the handler as a promise, so it needs an await. The proxy test
now mocks it as a promise for that reason — a friendlier mock would
let the same bug back in.
- A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
/common/, and the redirect really did go there. That would let any
Microsoft account sign in, including a private one, and it would never
look broken. It now refuses to start in production.
Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.
Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| b3a0af2b8f |
Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.
Was dabei ersatzlos verschwindet:
- fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
Am direkten Zugang ist eine Abfrage eine Abfrage.
- sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
abgesichert, sondern weg.
- lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.
Nebenbei besser geworden, weil der direkte Zugang es erlaubt:
- Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
Lesestand statt in fünf unabhängigen Anfragen.
- Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
eingebetteten Ressource mit !inner — eine Person mit mehreren
Zuordnungen über die Zeit erschien dort mehrfach.
- Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
- Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
- Namen an Ereigniszeilen kommen aus einem Join statt aus einem
Nachschlag, der ausserhalb der Transaktion lag.
Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.
Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.
Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
|
|||
| a66263a96e |
Put the session context under the app's own control
Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".
Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.
Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.
Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.
Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:
- Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
withUser() — und das öffnet immer eine Transaktion.
- set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
- Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
erzeugt zwei Fehler.
- Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.
tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.
Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.
Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
|
|||
| 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.
|
|||
| 35f17858d8 |
Build the org tree in the OM model as a tested pure function
Constructing the tree is where parent links, chief positions and number ranges get wired up wrongly without anyone noticing — a team under the wrong Bereich looks perfectly plausible in the org chart. So the construction is a pure function taking an id generator, and the checks that matter are asserted rather than eyeballed: exactly one root, every unit's parent of the expected type, every unit reaching the root, one chief position per unit, unique numbers in the right ranges, and every position pointing at a real unit and a real job. Also introduces the job catalogue this model needs. job_title was free text per person, so "Schlosser:in" and "Schlosser" could coexist and no breakdown by occupation was possible; jobs are now deduplicated by title and shared across positions. Every Abteilung gets a chief position, which is the level the old three-table model had no room for. Correction to the previous commit message: it claimed the cut-over could follow later while the old tables kept working. It cannot. The legacy resolve_manager_for() finds a Bereichsleitung by "division_id = X and team_id is null and org_level = 1", and an Abteilungsleitung satisfies the same predicate — its LIMIT 1 would then pick one of the two arbitrarily. The two models cannot both be correct once the new level is populated, so the remaining work is a single cut across the 11 RPCs that read the legacy columns, not a gradual migration. |
|||
| 4b9c23472c |
SAP OM: org units, jobs, positions, and a derived reporting line
The org structure was three fixed tables — divisions -> departments -> teams — with people hanging directly off them and a hand-maintained manager_id. The depth was therefore wired into the schema: an Abteilungsleitung could not exist without a migration, and a team directly under a Bereich not at all. That is what this replaces. The SAP OM object types, one table each: O org_units recursive over parent_id C jobs catalogue, so many positions can share a job S om_positions belongs to exactly one org unit P employees existing table A012 om_positions.is_chief "ist Leiter von" A008 position_assignments "Inhaber ist", time-dependent Two consequences worth stating, because they are the point of the exercise: - GF/Bereich/Abteilung/Team are now a label (unit_type), not a structure. Adding a fifth level, or hanging a team straight off a Bereich, becomes a data question rather than a migration. - Nobody hangs off an org unit any more: person -> position -> unit. A vacancy stops being its own concept — it is a position with no current assignment. The reporting line is derived rather than stored: an ordinary position reports to the chief of its own unit, a chief to the chief of the parent unit, and if that chief is vacant or on a long-term absence it keeps climbing. An unfilled Abteilungsleitung therefore needs no special case — it is simply skipped. Both ids come back, formal and acting, so the UI can show a stand-in as a stand-in instead of passing it off as the real manager. The rule exists twice, as om_reporting_lines() in SQL and resolveReportingLines() in TypeScript, because the as-of chart computes it per date in the app and a round trip per date change would buy nothing. Two copies drift silently — the org chart would just show a different manager than the export — so an integration test runs both over the whole roster and requires identical answers, plus that every line terminates at the top. Unit tests cover the rule itself: unfilled levels, several absent levels in a row, nobody above, a chief who also leads the parent unit, and a cycle in parent_id, which is an ordinary column an import could get wrong. Additive so far. The old tables still stand and the app still reads them; the cut-over follows. |
|||
| 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`). |