Compare commits

...

43 Commits

Author SHA1 Message Date
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>
2026-08-16 19:57:09 +02:00
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>
2026-08-16 19:46:24 +02:00
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>
2026-08-16 19:44:48 +02:00
91b2b3406b Stop waiting on the network eleven times per page
The app got slower as pages grew, and the reason was not the queries. It
was their number.

A transaction is pinned to one connection, and a connection runs queries
one after another. Every Promise.all in a withUser block looked like
concurrency and was a queue. Measured against the real database: the
round trip is ~36 ms, ten trivial `select 1` over one connection take
343 ms, over ten connections 39 ms. Nothing here is slow — the whole
dashboard payload is under 200 kB, and every table is around a thousand
rows.

More connections is the wrong answer: the RLS session context is per
transaction, so parallel reads mean parallel transactions, and those
multiply the connections the database will grant. Fewer round trips
instead. Postgres will return each sub-select as its own JSON column of
one result.

Per page view, counting the transaction frame:

  shell (paid by every page)  10 → 4
  overview                    14 → 5
  employee file               14 → 7
  employee list                8 → 6

The overview plus its shell went from 24 round trips to 9 — about 860 ms
of pure waiting down to about 320 ms.

The one trap is documented where it bites: inside json_agg, Postgres
formats values itself and the driver's parsers (lib/db/pool.ts) never
see them. Dates, numerics and uuids come out identical; timestamptz does
not — "+00:00" where the driver gives "…Z". Timestamps are compared as
strings in lib/history.ts to decide what happened later, and those two
forms sort against each other wrongly. Every timestamptz in a bundled
query therefore goes through zeitstempel(), which was checked
character-for-character against the driver.

Four loaders moved out of their pages into lib/ so the number of round
trips can be measured without building a React tree, and so the new path
could be held against the old one field by field: same rows, same order,
same strings, for the overview and for four employee files chosen to
differ (with history, a chief, a planned entry, one with dependents).

withUser now counts the queries in each transaction and says so in
development past a threshold. Without that, this grows back: each new
tile brings its own query, and nobody notices until everybody does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:31:36 +02:00
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>
2026-08-16 18:56:05 +02:00
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>
2026-08-15 11:44:21 +02:00
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>
2026-08-15 11:31:41 +02:00
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>
2026-08-14 13:27:26 +02:00
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>
2026-08-14 13:13:01 +02:00
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>
2026-08-14 13:07:41 +02:00
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>
2026-08-14 12:56:21 +02:00
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>
2026-08-14 12:44:00 +02:00
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>
2026-08-14 12:33:14 +02:00
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>
2026-08-14 11:44:03 +02:00
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>
2026-08-14 07:06:58 +02:00
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>
2026-08-13 21:35:51 +02:00
9308096754 Search names first, and only fall back to job titles
"Winkler M" returned four people, two of whom are not called M: Karin
Winkler is a Montagemitarbeiterin and Katharina Winkler a
Maschinenbedienerin. The job title was searched with the same weight as
the name, so a single letter matched the start of a job word just as
readily as the start of a first name.

Searching job titles is worth keeping — "dreher" finding the CNC-Dreher
is useful. So the search is now tiered: names alone first, and the job
title joins in only when the names return nothing at all. A minimum word
length would have been the simpler rule, but any threshold is a guess;
this one is decided by the data in front of it.

Checked against the live data: "winkler m" gives Martin and Magdalena,
"winkler h" Hannah, "dreher" and "montage" still find their trades, and
"winkler montage" finds Karin Winkler — no name matches both words, so
the fallback does what was meant.

When the fallback runs, the result line says so. Without that, a list of
people whose names look nothing like the query reads as though the
search invented them.

Costs one small count query, and only when text was typed.

Not verified with next build: a dev server from an earlier session is
holding .next, and the user is testing in it. tsc, eslint and 297 tests
are green, and the search itself was run against the database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:13:41 +02:00
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>
2026-08-13 21:07:22 +02:00
272e8b1acf Put the fields back in one statement, not one at a time
Every deletion of a Vertragsänderung failed with "new row for relation
employees violates check constraint chk_weekly_hours". The revert wrote
one UPDATE per field, and chk_weekly_hours ties two of them together:
Vollzeit means exactly 38.5 hours, Teilzeit means something in between.
Setting the employment type back to Vollzeit while 37 hours still stood
produced precisely the state the constraint forbids. It hit nearly every
contract change, because the form changes those two together.

Collecting the assignments and writing them in a single UPDATE removes
the intermediate state entirely. The state being restored was valid once
— it is in the history because it was — so restoring it whole is safe.

A violation can still be real: if a later change touched one of a
coupled pair on its own, the old value no longer fits today's state.
That case is caught and reported as a sentence instead of surfacing a
database error in a toast.

My tests did not catch this, and could not have: the revert lives in SQL
and the suite has no way to run it. What did catch it was HR clicking
the button. The rehearsal script now covers the reported case, an
unrelated single-field revert, and the genuine conflict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:00:47 +02:00
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>
2026-08-13 20:56:48 +02:00
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>
2026-08-13 17:57:10 +02:00
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>
2026-08-13 17:44:20 +02:00
578ce696f0 Correct the policy count in the places that quote it
Six comments and doc lines put the number of RLS policies at 58. It is
21 — counted from pg_policy while building the data catalogue. The
figure appears in load-bearing prose ("all 58 policies call
is_hr_user()", "all 58 policies stay unchanged"), where being wrong by a
factor of three invites someone to go looking for the missing thirty-
seven.

The two occurrences inside supabase/migrations/ stay as they are. That
file already ran against the database; its comments record what was
believed at the time, and editing them would make the file differ from
what was applied for no gain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:33:54 +02:00
917911457e Stop the seed handing out addresses that look like real ones
Every employee address in the database sat on test.manner.at, a domain
that reads like a company one. employees.email is the *private* address,
so an address shaped like a company mailbox invites being taken for one
— and eventually being written to. All 856 rows now sit on
privat.alpenwerk-test.at, rebuilt from first and last name, and the seed
generates the same domain so a reseed does not bring the old one back.

Umlauts are spelled out the way they are here (Höller becomes hoeller),
other accents are flattened, and where two people share a name the
personnel number is appended.

The first attempt got this wrong in a way worth recording. It wrote
ma<number>@ for all 856 rows instead of the intended name form, and the
check I had built only asked whether the results were unique and
well-formed — which they were. Two defects, both invisible to that
check: '\.+' inside a SQL literal was read as "any character, one or
more" and collapsed the whole local part to a single dot, and the
replacement string for the accent mapping had one character too many, so
the mapping was shifted. The fix uses '[.]+', a character class needing
no escape at all, so it no longer depends on how the connection treats
backslashes.

Untouched on purpose: app_users.email and profiles.email are the sign-in
accounts, and rewriting those would lock people out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:00:29 +02:00
64eb155dda Write down what is actually in the database
The one document describing the schema, docs/data-model.md, predates two
rebuilds. It names divisions/departments/teams and a positions table that
no longer exist, describes Supabase auth with an anon key and a service
role that were removed, and puts the policy count at 58 when it is 21.
Anyone reading it to understand the data would have been misled on every
count.

docs/datenkatalog.md replaces it, and was not typed up from memory: the
columns, defaults, keys and check constraints were read out of
information_schema and pg_catalog on the running database. Fifteen
tables, 142 columns, ten enum types, 21 policies. Where a rule appears in
prose, the constraint it comes from is named next to it.

Some of it only became visible by asking the database rather than the
migrations. generate_company_email and the is_hr_admin pair are still
defined but nothing calls them any more. Position numbers look like a
six followed by seven digits because the generator builds them that way,
not because anything enforces it — the column requires only uniqueness.
monthly_salary_gross is dead weight kept in case old rows hold data.

Three claims I drafted were wrong and the database said so: the position
number format, the event trigger's name (ensure_rls, the function behind
it is rls_auto_enable), and which tables deviate from the plain
is_hr_user() policy.

The old document keeps a pointer at the top instead of being deleted —
it is linked from the security review, and a stale document that says so
is more useful than a dead link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:46:49 +02:00
0b8f874fa5 Let the export select on everything the data model holds
The export offered four criteria — unit, location, status, employment
type — while the employee record carries around twenty selectable
attributes. Anything else had to be filtered by hand in Excel afterwards,
which is how a payroll hand-off stops matching the application it came
from.

All of them are now filters: contract type, blue/white collar,
collective agreement, paygrade, internal/external, gender, company car
and its drivetrain, works council, lateral leadership, C-level, type of
long-term absence, weekday worked, dependents on file, and open ranges
for entry, exit, birth date and weekly hours. The unit filter covers
every level rather than only divisions, so a single department can be
selected without going the long way round.

They live in one table in lib/report-criteria.ts, which the filter panel
builds itself from, the parser validates against, and the query turns
into conditions. A new criterion is one entry there and nothing else —
and it cannot end up working in the report while being silently ignored
by the export.

The two export links and the saved-report config now carry the query
string through as it stands instead of listing the parameters they know
about. That enumeration was the actual defect: adding a filter meant
remembering three separate places, and forgetting one produced an export
that quietly disagreed with the figure on screen.

Validation is not housekeeping here. These values reach SQL comparisons
and the download filename, i.e. a Content-Disposition header; what is not
in the list does not get through.

The company car dropdown leaves the employee list. It is one of twenty
equals under Berichte now, where the selection can also be exported —
which was the point of asking in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:40:47 +02:00
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>
2026-08-12 12:40:31 +02:00
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>
2026-08-11 22:03:54 +02:00
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>
2026-08-11 21:47:04 +02:00
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>
2026-08-11 21:33:28 +02:00
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>
2026-08-11 21:27:00 +02:00
53d5d41784 Search a name by any of its words, in any order
Reported from use: typing "Winkler micha" suggests there is no Winkler at
all, when there are fourteen. The search compared the whole term against
each field separately, so a two-word entry matched nothing — neither the
first name nor the last name contains "Michael Winkler" as a string. Both
orders failed; the report noticed one of them.

The term is now split on whitespace and every word must match somewhere.
That is more than was asked — the request was to search surname first — but
reversing the expected order only mirrors the problem: you would still have
to remember which way round it goes. "Winkler kath" and "kath Winkler" both
find the two Katharina Winklers now, and "Winkler Produktmanager" finds the
two in that job.

Matching runs against the concatenated name rather than the separate
columns, because that is exactly what idx_employees_name_trgm indexes. The
old query could not use it.

A second defect in the same block: the personnel-number branch tested
/^d+$/ — a missing backslash, so it matched strings of the letter d and
never a number. Searching "3488" fell through to the name search and found
nothing. It now reaches Peter Bauer.

Verified against the live database, before and after, for both orders and
for a plain surname, which still returns all fourteen.

One thing the report's screenshot cannot show any more: there is no Michael
Winkler in the current data. The database was reseeded, and those names are
from the previous set — worth knowing before checking with that exact name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:32:45 +02:00
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>
2026-08-11 19:29:08 +02:00
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>
2026-08-10 16:23:21 +02:00
92685f0ac0 Only staff a position while it exists
hire_employee and transfer_employee checked whether the position was free,
never whether it was there. Someone could be hired today onto a position
that starts in October, or onto one that lapsed in spring: the assignment
sat in the database while the position was absent from the org chart, and
the person hung off a structure that did not exist on their entry date.

That stopped being theoretical when the positions view began showing future
positions — they now appear in the same picker the hire wizard uses. This is
the rule that makes showing them safe.

The date of the assignment must fall in [valid_from, valid_to). valid_to is
exclusive throughout the model, as in lib/positions.ts.

Second correction in the same place: occupancy only looked at assignments
with an open end, so one ending later was invisible and the position could
be double-booked — the same gap the vacancy list had.

And a defect the verification exposed rather than the report: the work_days
default in hire_employee never applied. `array(select …)` over a missing key
yields an empty array, not null, so coalesce kept `{}` and the CHECK
constraint refused the row. Invisible through the wizard, which always sends
them and will not proceed without — but a default that defaults to nothing
is worse than none, because it reads as though the case was considered.

Verified against the live database, all rolled back: a hire onto a future
position is refused naming the date it begins, a transfer likewise, a hire
onto a currently valid one succeeds — and now also succeeds without
work_days, arriving with Mo–Fr.

The migrations match on a pattern rather than literal text: the function
bodies carry CRLF, and a literal search would have found nothing while the
migration reported success. Both refuse to proceed if the pattern matches
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:10:18 +02:00
d8a1fdf43b Reinstate the Vercel build settings
Reverts 61ccce5, which reverted ecbda3f. The decision came back to Vercel,
so the two platform accommodations return: output: "standalone" is
conditional on VERCEL again, and /api/import goes back to 60 seconds, the
free tier's ceiling.

The Docker path is unaffected and stays documented — including the internal
network notes and deploy/Caddyfile written in between, which remain correct
for anyone taking that road. DEPLOYMENT.md conflicted at the top and now
carries both introductions instead of one replacing the other.

Verified with VERCEL=1: builds clean and emits no standalone directory.

Stated once and recorded here rather than repeated: Vercel's Hobby plan
excludes commercial use, and this is a company's HR system. Defensible while
the database holds nothing but the 852 invented people from the seed;
Pro at $20/month is the licensed path once real personnel data is in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:32:47 +02:00
780f8fe2b7 Write down what an internal deployment actually needs
The target is a VM inside the company network, reachable only from there.
Two consequences decide whether this works at all, and both are easy to
discover too late — after the firewall rules are already written.

The server needs outbound access even though nothing comes in. Auth.js
exchanges the authorisation code for a token server-side and fetches the
issuer's configuration, so login.microsoftonline.com must be reachable from
the VM; the database likewise. That the person signs in through their own
browser is not enough, which is the assumption worth naming before someone
builds a closed network around it.

HTTPS is not optional either: Entra accepts http only for localhost. The
practical route without public reachability is a public DNS name pointing at
a private address and a certificate obtained through the DNS challenge —
allowed, common, and it yields a normally trusted certificate while the
server stays unreachable from outside. deploy/Caddyfile does that, and the
alternative (self-signed, trusted on every workstation) is written down with
its cost.

docker-compose now publishes port 3000 on 127.0.0.1 only. It was on every
interface, so the same service also stood there unencrypted, and one gap in
the firewall was enough. The proxy is the only way in.

AUTH_URL is documented for the same reason a comment sits in the Caddyfile:
behind a proxy the container does not see the name the browser used, and the
callback would point somewhere nobody can reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:26:14 +02:00
56662c0775 Describe what is actually deployed, now that the target is a Linux server
The revert restored three statements that stopped being true earlier today.

"Nicht containerisiert: Supabase (Datenbank + Auth)" — authentication is no
longer Supabase, it is Entra ID with an Auth.js session cookie, and the
database is any PostgreSQL 15 or later reached through DATABASE_URL. Supabase
is one option among several now, not the architecture.

The CI/CD note told the reader to pass --build-arg values for NEXT_PUBLIC_*.
Those variables no longer exist and the Dockerfile stopped taking build
arguments today. Following it would produce a puzzling failure; the point
now is the opposite one, that no build arguments are needed at all and the
same image runs everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:23:30 +02:00
61ccce5456 Revert "Make the build fit Vercel without breaking the container"
This reverts commit ecbda3f. The deployment goes to a Linux server instead,
so the two accommodations no longer earn their place: output: "standalone"
returns to unconditional, which is what the Dockerfile wants, and
/api/import goes back to 120 seconds — the free-tier ceiling that forced 60
does not apply outside a serverless platform, and a large import benefits
from the headroom.

The Vercel section in DEPLOYMENT.md goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:21:58 +02:00
ecbda3f3a5 Make the build fit Vercel without breaking the container
Two settings were wrong for a platform build.

output: "standalone" tells Next.js to emit a self-contained server, which is
what the Dockerfile copies in — and what Vercel neither needs nor expects,
since it builds and packages the app itself. It is now conditional on the
VERCEL variable, which every build there sets, so each path gets what it
wants. Verified both ways: with VERCEL=1 no standalone directory appears,
without it one does.

/api/import declared maxDuration = 120. The free tier caps at 60 and refuses
anything higher, so the deployment would have failed on a value chosen for a
self-hosted server. Lowered, with the reason and the Pro ceiling written
next to it.

DEPLOYMENT.md now covers both paths, and says plainly that the repository
cannot be connected: git.elycon.solutions is self-hosted, and Vercel's git
integration only speaks GitHub, GitLab and Bitbucket. Deploying from the
workstation with the CLI works with any repository and is the shorter road;
mirroring to GitHub is written down as the alternative, with its cost — two
remotes to keep in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:17:24 +02:00
27cfd6431f Let a second person be activated at all
profiles.id referenced auth.users. Sign-in goes through Auth.js now and
creates nothing there, so a new colleague could sign in, receive an
app_users row, and then be impossible to authorise: the profiles row needed
to grant HR access could not be inserted. She would see "Kein HR-Zugriff"
with no way to change it.

All eight foreign keys in the public schema now point at app_users, walked
from the catalogue rather than written out — their names come from different
migrations and one transcribed wrongly means it silently stays behind. The
delete behaviour is preserved: profiles still cascades from the account,
audit and note fields do not, because an entry must not vanish when an
account is removed.

Every referenced value was already present in app_users, so nothing moved;
only the guarantee changed. A backfill from profiles runs first anyway, for
copies of this database where someone created something in between.

The check at the end does the thing that matters: it creates a second
account with a profile and removes it again. Counting constraints would have
passed while the actual case still failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:15:27 +02:00
0d8af7cdf0 Refuse a save that changes nothing instead of reporting success
update_position returned quietly when no field differed, and the interface
answered "Planstelle geändert." — a confirmation for something that had not
happened. It now raises, and the message says so.

This is reachable without the user doing anything wrong: the chief checkbox
is dropped on the way out when the unit already has a chief position, so a
save consisting only of that tick arrives as an empty change set. The reply
was a green toast and an unchanged list, which sends someone looking in the
wrong place.

It also separates the two explanations for "I saved and nothing happened",
which is why it went in now: an empty change set is refused in red, so a
green confirmation with a stale card can only mean the page did not reload.

Verified against the live database: an unchanged payload is refused, a
changed one goes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:57:31 +02:00
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>
2026-08-05 12:26:12 +02:00
123 changed files with 13285 additions and 740 deletions

View File

@@ -1,17 +1,83 @@
# Deployment mit Docker
# Deployment
Dieser Guide beschreibt, wie die App (bisher auf Vercel deployed, siehe
`vercel.json`) stattdessen als Docker-Container auf einem beliebigen Server
läuft.
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also
jederzeit möglich.
| | passt, wenn |
|---|---|
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg |
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll |
**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen),
die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach
ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe
[docs/entra-sso.md](docs/entra-sso.md)).
## Vercel
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
```bash
npx vercel login
npx vercel link
npx vercel --prod
```
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
Personen ist das eher Vorteil als Nachteil.
### b) Zusätzlich nach GitHub spiegeln
```bash
git remote add github git@github.com:<konto>/alpenwerk-hr.git
git push github feat/sap-om-org-model
```
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
### Danach
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
nicht nötig, Vercel setzt den Host selbst.
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
vielen tausend Zeilen ansteht.
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
verbraucht.
## Deployment mit Docker
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
einem eigenen Linux-Server läuft.
## Was wird containerisiert – und was nicht
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
- **Nicht containerisiert:** Supabase (Datenbank + Auth). Die App verbindet
sich per URL/Key zu einem bestehenden Supabase-Projekt (Cloud oder
selbst gehostet) – das bleibt unverändert. `supabase/` in diesem Repo ist
nur die lokale Dev-/Migrations-Umgebung (`supabase start`), kein Teil des
Deployments.
- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über
`DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein
Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS,
Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die
Migrations- und Entwicklungsumgebung, kein Teil des Deployments.
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
keinen Anmeldedienst, der mit ausgerollt werden müsste.
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
@@ -19,6 +85,39 @@ läuft.
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
und demselben Bearer-Secret, das die Route bereits erwartet.
## Betrieb im Firmennetz — zwei Dinge vorab
Die App läuft intern, aber sie ist **nicht** von der Aussenwelt unabhängig.
Beides vor der Installation klären, sonst scheitert es am Ende an der
Firewall:
**1. Der Server braucht ausgehenden Zugang.** Eingehend aus dem Internet
nichts, ausgehend zwingend:
| Ziel | Wofür | Ohne das |
|---|---|---|
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL-
Instanz im selben Netz statt Supabase.
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
**öffentlicher DNS-Name, der auf die private Adresse zeigt** (z. B.
`hr.elycon.solutions` → `10.x.x.x`) und ein Zertifikat über die
DNS-Challenge. Das ist zulässig, verbreitet, und liefert ein regulär
vertrauenswürdiges Zertifikat, ohne dass der Server je aus dem Internet
erreichbar ist.
Beispielkonfiguration: [`deploy/Caddyfile`](deploy/Caddyfile).
Die Alternative — selbst signiertes Zertifikat — bedeutet, es auf jedem
Arbeitsplatz als vertrauenswürdig zu hinterlegen. Bei zwei Personen machbar,
bei zwanzig nicht.
## Voraussetzungen
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
@@ -83,30 +182,42 @@ docker compose up -d --build
```
Alternative für CI/CD (Image einmal bauen, überall pullen): Image in einer
Registry (GHCR, Docker Hub, …) bauen und pushen, auf dem Server nur
Registry bauen und pushen, auf dem Server nur
`docker compose pull && docker compose up -d` ausführen. Dafür in
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen und
den Build in der CI-Pipeline mit den `--build-arg`-Werten für
`NEXT_PUBLIC_*` laufen lassen.
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen.
Build-Argumente braucht es dabei **keine**: das Abbild enthält keine
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
Dasselbe Abbild läuft damit in Test und Produktion.
## 4. Reverse Proxy + HTTPS
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
Validierung. Beispiel mit [Caddy](https://caddyserver.com/) (automatisches
HTTPS via Let's Encrypt):
Validierung.
```caddyfile
# /etc/caddy/Caddyfile
hr.example.com {
reverse_proxy localhost:3000
}
Fertige Konfiguration: [`deploy/Caddyfile`](deploy/Caddyfile) — mit
DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
[oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
```bash
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
`docker-compose.yml` published Port 3000 aktuell auf den Host – bei
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf
`127.0.0.1:3000:3000` eingeschränkt werden, damit der Container-Port nicht
direkt von außen erreichbar ist.
`docker-compose.yml` veröffentlicht Port 3000 bewusst nur auf
`127.0.0.1` — die App ist also ausschliesslich über den Proxy erreichbar,
nicht daneben unverschlüsselt.
Zusätzlich in die `.env`:
```
AUTH_URL=https://hr.elycon.solutions
```
Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der
Container sieht — und das ist hinter dem Proxy nicht der Name, den der
Browser benutzt hat.
## 5. Updates ausrollen

View File

@@ -88,7 +88,7 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
Ersatzkontrolle.
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
siehe [`docs/datenkatalog.md`](docs/datenkatalog.md#zugriffsschutz).
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
@@ -122,7 +122,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
## Supabase-Hinweise
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
Zusammenfassung: [`docs/data-model.md`](docs/data-model.md).
Fassung, aus der laufenden Datenbank erzeugt:
[`docs/datenkatalog.md`](docs/datenkatalog.md).
- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt)
bzw. `supabase start` + automatische Anwendung für lokale Entwicklung.
- `supabase/seed.ts` und `.env.test.local` sind nur für lokale

View File

@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload);
@@ -14,6 +14,14 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalid
}
export async function hireEmployee(payload: {
/**
* Wird eingegeben, nicht vergeben.
*
* Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene
* Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei
* Nummern. Die Datenbank weist eine bereits vergebene Nummer ab.
*/
personnel_number: number;
first_name: string;
last_name: string;
title_prefix?: string[];
@@ -22,14 +30,13 @@ export async function hireEmployee(payload: {
birth_date: string;
sv_nummer?: string;
/**
* Pflicht, weil employees.email NOT NULL ist.
* Die **private** Adresse, freiwillig.
*
* Der Assistent hat die Adresse immer erhoben und in der Zusammenfassung
* angezeigt — sie fehlte nur in dieser Signatur und wurde deshalb
* stillschweigend verworfen. Jede Einstellung scheiterte danach an der
* Spaltenbedingung.
* Sie war einmal Pflicht, weil die Spalte NOT NULL war — für eine private
* Angabe die falsche Vorgabe: wer keine hat, musste eine erfinden. Bleibt
* eindeutig, wenn angegeben.
*/
email: string;
email?: string;
phone?: string;
position_id?: string;
team_id?: string;
@@ -47,8 +54,15 @@ export async function hireEmployee(payload: {
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
dienstwagen_art?: DienstwagenArt | null;
emergency_contact_name?: string;
emergency_contact_phone?: string;
emergency_contact_relation?: string;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
has_kuendigungsschutz?: boolean;
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
kuendigungsschutz_bis?: string | null;
}): Promise<ActionResult & { employeeId?: string }> {
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
@@ -115,6 +129,10 @@ export async function recordKarenzReturn(payload: {
return_date: string;
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
weekly_hours?: number;
/** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */
reduction_reason?: string;
/** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */
teilzeit_bis?: string;
}): Promise<ActionResult> {
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
@@ -129,7 +147,19 @@ export async function changeEmployeeData(payload: {
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise<ActionResult> {
/**
* `position_id` ist Pflicht — die Datenbankfunktion verlangt sie seit jeher.
*
* Die alte Stelle taugt nicht als stille Vorgabe: sie kann inzwischen besetzt
* oder ausgelaufen sein. Sie fehlte hier nur in der Signatur, weshalb jede
* Wiedereinstellung an einer Meldung scheiterte, die im Dialog nicht zu
* beheben war.
*/
export async function rehireEmployee(payload: {
employee_id: string;
rehire_date: string;
position_id: string;
}): Promise<ActionResult> {
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
@@ -153,6 +183,41 @@ export async function deleteEmployeeDependent(payload: {
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
/**
* Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück.
*
* Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank —
* sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die
* Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts);
* kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt.
*/
export async function deleteHistoryEntry(payload: {
history_id: string;
employee_id: string;
}): Promise<ActionResult> {
return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]);
}
/**
* Berichtigt Wert und/oder Datum eines Historieneintrags.
*
* Geändert wird nur das „Nachher" — was vor der Änderung galt, ist nicht
* nachträglich beschliessbar. Welcher Wert danach in den Stammdaten steht,
* leitet die Datenbank je Feld aus dem jüngsten Eintrag ab, der es trägt.
*/
export async function updateHistoryEntry(payload: {
history_id: string;
employee_id: string;
event_date?: string;
werte: { feld: string; nachher: string | null }[];
}): Promise<ActionResult> {
return callRpc(
"update_history_entry",
{ history_id: payload.history_id, event_date: payload.event_date, werte: payload.werte },
[`/employees/${payload.employee_id}`, "/audit"]
);
}
export async function addEmployeeNote(payload: {
employee_id: string;
category: NoteCategory;

View File

@@ -7,7 +7,7 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc";
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "delete_position",
fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
payload: Record<string, unknown>,
revalidate: string[]
): Promise<ActionResult> {
@@ -26,7 +26,42 @@ export async function createPosition(payload: {
return callRpc("create_position", payload, POSITION_PATHS);
}
/**
* Ändert eine Planstelle.
*
* Weggelassene Felder bleiben, wie sie sind. `valid_to: null` beendet die
* Befristung ausdrücklich — deshalb ist es hier `string | null` und nicht
* optional: „nicht mitgeschickt" und „auf leer setzen" müssen unterscheidbar
* bleiben.
*/
export async function updatePosition(payload: {
position_id: string;
org_unit_id?: string;
job_title?: string;
is_chief?: boolean;
valid_from?: string;
valid_to?: string | null;
}): Promise<ActionResult> {
return callRpc("update_position", payload, POSITION_PATHS);
}
export async function deletePosition(positionId: string): Promise<ActionResult> {
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
}
/**
* Kontiert eine Planstelle auf eine andere Kostenstelle — zum Stichtag.
*
* Kein Feld im Änderungsdialog, sondern ein eigener Vorgang, weil es einer
* ist: die laufende Kontierung wird beendet, die neue beginnt. Die alte
* einfach zu überschreiben hiesse, die Kosten der Vergangenheit nachträglich
* anderswohin zu buchen.
*/
export async function setPositionCostCenter(payload: {
position_id: string;
cost_center_id: string;
valid_from: string;
}): Promise<ActionResult> {
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
}

View File

@@ -3,9 +3,8 @@ import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
import { loadPlacements, loadReportingLines } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions";
import { breadcrumbLabel } from "@/lib/org";
import { loadEmployeeDetail } from "@/lib/employee-detail-data";
type PageProps = { params: Promise<{ id: string }> };
@@ -13,66 +12,12 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
const { id } = await params;
const today = todayIso();
const data = await withUser(await currentUserId(), async (tx) => {
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
// achthundert.
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
await Promise.all([
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
loadReportingLines(tx, today, { employeeId: id }),
loadReportingLines(tx, today, { actingManagerId: id }),
tx
.selectFrom("employee_history")
.selectAll()
.where("employee_id", "=", id)
.orderBy("event_date", "desc")
.orderBy("created_at", "desc")
.execute(),
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
loadOrgMaps(tx),
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
loadOpenPositions(tx),
]);
if (!employee) return null;
const line = ownLines[0] ?? null;
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte.
const relatedIds = Array.from(
new Set(
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
);
const relatedRows = relatedIds.length
? await tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "job_title", "status"])
.where("id", "in", relatedIds)
.execute()
: [];
return {
employee,
line,
reports,
history,
dependents,
notes,
orgMaps,
placement: placements.get(id) ?? null,
openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])),
};
});
// Was die Akte liest und in wie vielen Rundreisen, steht in
// lib/employee-detail-data.ts.
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
if (!data) notFound();
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data;
return (
<EmployeeDetail
@@ -86,6 +31,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
}
}
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
kostenstelle={kostenstelle}
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
// zweimal da.

View File

@@ -6,16 +6,24 @@ import { CARD_CLASS } from "@/components/ui/Card";
import { Pagination } from "@/components/ui/Pagination";
import { StatusChip } from "@/components/ui/StatusChip";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { sql, withUser } from "@/lib/db";
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
import { fmtDate, fmtName, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import type { EmploymentStatus } from "@/lib/supabase/types";
const PAGE_SIZE = 15;
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
type SearchParams = {
q?: string;
division?: string;
status?: string;
location?: string;
page?: string;
};
type EmployeesPageProps = {
searchParams: Promise<SearchParams>;
@@ -43,7 +51,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
.map((s) => s.trim())
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => {
const { orgMaps, employees, count, placements, ueberPosition } = await withUser(await currentUserId(), async (tx) => {
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
// selbst sitzt nur die Bereichsleitung.
@@ -55,8 +63,11 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
// auflösen konnte.
const base = () => {
let q = tx.selectFrom("employees");
// Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette
// einmal für die Seite und einmal für die Zählung in *einer* Abfrage
// stehen kann.
const base = (nurNamen: boolean, eb: OrgEb = tx as never) => {
let q = eb.selectFrom("employees");
if (unitFilter) {
// Nach Organisationseinheit gefiltert wird über die *laufende*
@@ -78,15 +89,48 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
if (params.q) {
const term = params.q.trim();
if (/^d+$/.test(term)) {
// `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
// während das Muster auf „ddd" ansprang.
if (istPersonalnummer(term)) {
q = q.where("personnel_number", "=", Number(term));
} else {
// Als Parameter gebunden statt in die Abfrage geschrieben: die
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
// hier bedeutungslos.
const like = `%${term}%`;
// ── Wie hier gesucht wird ──────────────────────────────────
//
// **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber
// nicht in einer bestimmten Ordnung: „Winkler micha" und „micha
// Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand
// „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer,
// obwohl es vierzehn Winkler gibt.
//
// **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein
// „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle
// sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit
// „dreher" auch „CNC-Dreher:in" findet.
//
// **Namen vor Positionen.** Die Position mitzudurchsuchen ist
// nützlich („dreher"), darf aber eine Namenssuche nicht verwässern:
// bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin)
// und Katharina Winkler (Maschinenbedienerin) auf, weil ihre
// Position mit M beginnt. Deshalb wird zuerst nur über die Namen
// gesucht; nur wenn das *nichts* findet, kommt die Position dazu.
// Eine feste Mindestlänge fürs Wort wäre die einfachere Regel, aber
// jede Grenze wäre geraten — diese hier ergibt sich aus den Daten.
//
// Der Trigramm-Index auf dem zusammengesetzten Namen greift bei
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
const heuhaufen = nurNamen
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
q = q.where((eb) =>
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
eb.and(
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
suchMuster(term).map(([amAnfang, nachLeerzeichen]) =>
eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)])
)
)
);
}
}
@@ -101,39 +145,60 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
return q;
};
const [rows, total] = await Promise.all([
base()
.select([
"id",
"first_name",
"last_name",
"personnel_number",
"job_title",
"location_id",
"entry_date",
"employment_type",
"weekly_hours",
"status",
"absence_type",
])
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
// Seiten erscheinen oder auf keiner.
.orderBy("last_name")
.orderBy("id")
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.execute(),
base()
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.executeTakeFirst(),
]);
// Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht,
// wird die Position mitgesucht — eine zusätzliche, sehr kleine Abfrage,
// und nur bei einer Textsuche.
const sucheNachNamen = Boolean(params.q) && !istPersonalnummer(params.q!.trim());
const namensTreffer = sucheNachNamen
? Number((await base(true).select(({ fn }) => fn.countAll<string>().as("anzahl")).executeTakeFirst())?.anzahl ?? 0)
: 0;
const nurNamen = sucheNachNamen && namensTreffer > 0;
// Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach
// Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer
// Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts).
const { rows, total } = await tx
.selectNoFrom((eb) => [
jsonArrayFrom(
base(nurNamen, eb)
.select([
"id",
"first_name",
"last_name",
"personnel_number",
"job_title",
"location_id",
"entry_date",
"employment_type",
"weekly_hours",
"status",
"absence_type",
])
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
// Seiten erscheinen oder auf keiner.
.orderBy("last_name")
.orderBy("id")
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
).as("rows"),
jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll<string>().as("anzahl"))).as("total"),
])
.executeTakeFirstOrThrow();
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
// Seite, nicht für den ganzen Bestand.
const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
return { orgMaps, employees: rows, count: Number(total?.anzahl ?? 0), placements };
return {
orgMaps,
employees: rows,
count: Number(total?.anzahl ?? 0),
placements,
// Für den Hinweis über der Liste: wurde nach Namen gesucht, und hat es
// gereicht?
ueberPosition: sucheNachNamen && !nurNamen,
};
});
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
@@ -143,7 +208,14 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<Suspense>
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
</Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
<p className="text-sm text-ink-muted">
{count ?? 0} Mitarbeiter:innen gefunden
{/* Wenn kein Name passte, wurde nach der Position gesucht. Ohne diesen
Hinweis wirkt das Ergebnis, als hätte die Suche etwas erfunden. */}
{ueberPosition && (count ?? 0) > 0 && (
<span className="text-ink-muted"> · kein Namenstreffer, gesucht nach Position</span>
)}
</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm">
@@ -176,7 +248,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<Avatar firstName={e.first_name} lastName={e.last_name} />
<div className="min-w-0">
<div className="truncate font-semibold text-ink">
{e.first_name} {e.last_name}
{fmtName(e.first_name, e.last_name)}
</div>
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
</div>

View File

@@ -4,46 +4,23 @@ import { HireWizardProvider } from "@/components/hire/HireWizardContext";
import { AppShell } from "@/components/shell/AppShell";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { loadOpenNotes } from "@/lib/notes";
import { loadOpenPositions } from "@/lib/positions";
import { loadShellData } from "@/lib/shell-data";
export default async function AppLayout({ children }: { children: ReactNode }) {
const userId = await currentUserId();
if (!userId) redirect("/login");
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
// und damit nebenbei auf einem einheitlichen Lesestand.
const data = await withUser(userId, async (tx) => {
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
// Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS.
const profile = await tx
.selectFrom("profiles")
.select(["full_name", "email", "role", "is_active"])
.where("id", "=", userId)
.executeTakeFirst();
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
const [openPositions, locations, drafts, openNotes] = await Promise.all([
loadOpenPositions(tx),
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
tx
.selectFrom("hire_drafts")
.select(["id", "step", "payload", "updated_at"])
.where("created_by", "=", userId)
.orderBy("updated_at", "desc")
.execute(),
loadOpenNotes(tx),
]);
return { profile, openPositions, locations, drafts, openNotes };
});
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
const data = await withUser(userId, (tx) => loadShellData(tx, userId));
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
// ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen
// Anmeldeseite und versuchte es endlos erneut.
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
// wortlosen Anmeldeseite und versuchte es endlos erneut.
if (!data) redirect("/login?error=no_hr_access");
const userLabel = data.profile.full_name || data.profile.email || "";

View File

@@ -0,0 +1,43 @@
import { PrintChart } from "@/components/orgchart/PrintChart";
import type { OrgUnitNode } from "@/components/orgchart/types";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { loadOrgAsOf } from "@/lib/orgchart-data";
import { buildPrintModel } from "@/lib/orgchart-print";
import { parseIsoDateParam } from "@/lib/reports";
export const metadata = { title: "Organigramm drucken" };
type SearchParams = { asOf?: string };
// Eigene Route statt eines Druckstils auf der Organigramm-Seite.
//
// Die interaktive Ansicht ist eine Leinwand mit Zoom und Verschiebung; was
// davon auf Papier landet, hängt vom Zufallsstand der Ansicht ab. Hier wird
// stattdessen aus denselben Daten eine Seitenfolge gebaut, und der Stichtag
// wird aus der Adresse übernommen — wer im Organigramm einen Stichtag
// eingestellt hat, druckt genau den.
export default async function OrgChartPrintPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const today = todayIso();
const asOf = parseIsoDateParam(params.asOf) ?? today;
const { org, units } = await withUser(await currentUserId(), async (tx) => {
const [org, units] = await Promise.all([
loadOrgAsOf(tx, asOf),
tx
.selectFrom("org_units")
.select(["id", "org_number", "name", "parent_id", "unit_type"])
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.where("valid_from", "<=", asOf)
.orderBy("org_number")
.execute(),
]);
return { org, units };
});
const model = buildPrintModel(units as OrgUnitNode[], org.employees, org.vacancies);
return <PrintChart model={model} asOf={asOf} today={today} />;
}

View File

@@ -1,16 +1,17 @@
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
import { DraftsCard } from "@/components/dashboard/DraftsCard";
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
import { actionBadgeStyle } from "@/lib/colors";
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
import { divisionOf, loadOrgMaps } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions";
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
import { loadDashboardData } from "@/lib/dashboard-data";
import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format";
import { divisionOf } from "@/lib/org";
import { deriveStatusAsOf } from "@/lib/reports";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import type { HistoryEventType } from "@/lib/supabase/types";
// Each KPI carries a colour already; the accent bar repeats it in a second
// channel so the tiles are scannable as a row rather than six identical
@@ -37,9 +38,18 @@ const DOT_STYLES: Record<string, string> = {
Gehaltsanpassung: "bg-warning-text",
};
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
const KIND_LABEL = {
hire: "Eintritt",
exit: "Austritt",
return: "Rückkehr aus Abwesenheit",
note: "Wiedervorlage",
} as const;
export default async function DashboardPage() {
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ tage?: string; arten?: string }>;
}) {
// Built as strings, not by round-tripping a local Date through
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
// the previous year in UTC, which shifted the whole YTD window a day early
@@ -48,7 +58,14 @@ export default async function DashboardPage() {
const year = today.slice(0, 4);
const yearStart = `${year}-01-01`;
const yearEnd = `${year}-12-31`;
const in60Iso = addDaysIso(today, 60);
// Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen
// wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90
// statt 60 Tage bringt Zeilen ins Spiel, die sonst nirgends lägen.
const params = await searchParams;
const zeitraum = parseZeitraum(params.tage);
const arten = parseArten(params.arten);
const bisIso = addDaysIso(today, zeitraum);
const userId = await currentUserId();
@@ -71,106 +88,9 @@ export default async function DashboardPage() {
upcomingHires,
upcomingExits,
upcomingReturns,
upcomingNotes,
history,
} = await withUser(userId, async (tx) => {
const countIn = (types: readonly HistoryEventType[]) =>
tx
.selectFrom("employee_history")
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd)
.executeTakeFirst();
const [
drafts,
staffRows,
hiresYtd,
exitsYtd,
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
] = await Promise.all([
userId
? tx
.selectFrom("hire_drafts")
.select(["id", "step", "payload", "updated_at"])
.where("created_by", "=", userId)
.orderBy("updated_at", "desc")
.execute()
: Promise.resolve([]),
tx
.selectFrom("employees")
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
.orderBy("id")
.execute(),
// Entries/exits count history events, which is what the linked report
// counts too. `entry_date` would also sweep up rehires, whose event is
// logged as 'Wiedereintritt' — the tile and its destination then showed
// different numbers for the same year.
countIn(["Eintritt", "Wiedereintritt"]),
countIn(["Austritt"]),
loadOpenPositions(tx),
loadOrgMaps(tx),
loadPlacements(tx, { asOf: today }),
tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "entry_date"])
.where("status", "=", "Geplant")
.where("entry_date", ">=", today)
.where("entry_date", "<=", in60Iso)
.execute(),
tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "exit_date"])
.where("exit_date", "is not", null)
.where("exit_date", ">=", today)
.where("exit_date", "<=", in60Iso)
.execute(),
tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "karenz_return_date"])
.where("status", "=", "Karenz")
.where("karenz_return_date", "is not", null)
.where("karenz_return_date", ">=", today)
.where("karenz_return_date", "<=", in60Iso)
.execute(),
tx
.selectFrom("employee_history as h")
.leftJoin("employees as e", "e.id", "h.employee_id")
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
.orderBy("h.event_date", "desc")
.orderBy("h.created_at", "desc")
.limit(10)
.execute(),
]);
return {
drafts,
staffRows,
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
};
});
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
// active, and is counted by its own tile instead. FTE follows the same
// set: Karenz contributes no capacity, so including it would overstate
@@ -201,29 +121,53 @@ export default async function DashboardPage() {
.sort((a, b) => b.count - a.count);
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
const upcoming: UpcomingItem[] = [
type UpcomingItem = {
id: string;
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
employeeId: string;
label: string;
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
hinweis?: string;
date: string;
kind: keyof typeof KIND_LABEL;
};
const upcomingAlle: UpcomingItem[] = [
...(upcomingHires).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name),
date: e.entry_date,
kind: "hire" as const,
})),
...(upcomingExits).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name),
date: e.exit_date!,
kind: "exit" as const,
})),
...(upcomingReturns).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name),
date: e.karenz_return_date!,
kind: "return" as const,
})),
]
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8);
...(upcomingNotes).map((n) => ({
id: n.id,
employeeId: n.employee_id!,
label: fmtName(n.first_name, n.last_name),
hinweis: n.note_text,
date: n.due_date!,
kind: "note" as const,
})),
].sort((a, b) => a.date.localeCompare(b.date));
// Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter
// steht, wie viele es insgesamt sind. Wer alle sehen will, filtert enger
// oder geht in die Mitarbeiterliste.
const upcoming = upcomingAlle.slice(0, 8);
const weitere = upcomingAlle.length - upcoming.length;
// Each tile links to the view that shows what it counts, with the filters
// pre-applied.
@@ -308,24 +252,55 @@ export default async function DashboardPage() {
</Card>
<Card>
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
<CardTitle className="mb-2">Anstehend ({zeitraum} Tage)</CardTitle>
{/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim
Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */}
<Suspense fallback={<div className="mb-2 h-9" />}>
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
</Suspense>
<ul className="flex flex-col divide-y divide-border-subtle">
{upcoming.map((item) => (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/employees/${item.id}`}
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
>
<span className="min-w-0">
<span className="block truncate font-semibold text-ink">{item.label}</span>
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
</span>
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
</Link>
</li>
))}
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
{upcoming.map((item) => {
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
// haben eine untere Grenze, eine offene Aufgabe nicht.
const ueberfaellig = item.date < today;
return (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/employees/${item.employeeId}`}
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
>
<span className="min-w-0">
<span className="block truncate font-semibold text-ink">{item.label}</span>
<span className="block truncate text-xs text-ink-muted">
{KIND_LABEL[item.kind]}
{item.hinweis ? ` — ${item.hinweis}` : ""}
</span>
</span>
<span
className={`shrink-0 text-xs font-semibold tabular-nums ${
ueberfaellig ? "text-danger-text" : "text-ink-muted"
}`}
>
{ueberfaellig ? "überfällig " : ""}
{fmtDate(item.date)}
</span>
</Link>
</li>
);
})}
{upcoming.length === 0 && (
<p className="py-2 text-sm text-ink-muted">
{istEingeschraenkt(zeitraum, arten)
? "Zu dieser Auswahl steht nichts an."
: "Keine anstehenden Ereignisse."}
</p>
)}
</ul>
{weitere > 0 && (
<p className="mt-2 text-xs text-ink-muted">
… und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum.
</p>
)}
</Card>
<Card>
@@ -338,7 +313,7 @@ export default async function DashboardPage() {
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>

View File

@@ -1,22 +1,39 @@
import type { UnitOption } from "@/components/positions/CreatePositionModal";
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
import { daysBetweenIso } from "@/lib/format";
import { loadOrgMaps } from "@/lib/org";
import { loadOpenPositions } from "@/lib/positions";
import { currentUserId } from "@/lib/auth/session";
import { kostenstellenAbfrage } from "@/lib/cost-centers";
import { withUser } from "@/lib/db";
import { jsonArrayFrom } from "@/lib/db/json";
import { daysBetweenIso, todayIso } from "@/lib/format";
import { buildOrgMaps, orgMapsAbfragen } from "@/lib/org";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "@/lib/positions";
export default async function PositionsPage() {
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
const [openPositions, orgMaps, chiefRows] = await Promise.all([
loadOpenPositions(tx),
loadOrgMaps(tx),
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
// Fehlermeldung nach dem Absenden.
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
]);
return { openPositions, orgMaps, chiefRows };
const today = todayIso();
const { openPositions, orgMaps, chiefRows, kostenstellen } = await withUser(await currentUserId(), async (tx) => {
// Alles, was ohne Vorwissen geht, in einer Rundreise (lib/db/json.ts).
const g = await tx
.selectNoFrom((eb) => [
...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
jsonArrayFrom(kostenstellenAbfrage(eb, today)).as("kostenstellen"),
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
// Fehlermeldung nach dem Absenden.
jsonArrayFrom(
eb.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null)
).as("chiefRows"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
return {
orgMaps,
chiefRows: g.chiefRows,
kostenstellen: g.kostenstellen,
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
};
});
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
@@ -30,5 +47,5 @@ export default async function PositionsPage() {
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />;
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} kostenstellen={kostenstellen} />;
}

View File

@@ -15,11 +15,15 @@ import {
sumValues,
totalForRows,
} from "@/lib/reports";
import { parseCriteria } from "@/lib/report-criteria";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
type SearchParams = {
// Nur die Parameter, die diese Seite selbst auswertet. Die Auswahlkriterien
// stehen ebenfalls in der Adresszeile, werden aber geschlossen von
// parseCriteria gelesen — siehe lib/report-criteria.ts.
type SearchParams = Record<string, string | string[] | undefined> & {
mode?: string;
measure?: string;
group?: string;
@@ -27,7 +31,6 @@ type SearchParams = {
division?: string;
location?: string;
status?: string;
employment?: string;
asOf?: string;
eventType?: string;
from?: string;
@@ -50,6 +53,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
const measure = parseMeasure(params.measure);
const group = parseGroupDimension(params.group);
const split = parseSplitDimension(params.split);
const criteria = parseCriteria((k) => (typeof params[k] === "string" ? (params[k] as string) : undefined));
// The report data depends on neither the org lookups nor on who is signed
// in, so all three go out together. Against a hosted database a round trip
@@ -61,8 +65,8 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
// Teil dieser Seite war.
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
const { lookups, units, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
const [{ lookups, units, locations }, events, employees, savedReports] = await Promise.all([
loadOrgLookups(tx),
mode === "events"
? loadEventHistory(tx, {
@@ -78,7 +82,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
division: params.division,
location: params.location,
status: params.status,
employment: params.employment,
criteria,
asOf,
})
: Promise.resolve([]),
@@ -91,7 +95,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
.execute()
: Promise.resolve([]),
]);
return { lookups, divisions, locations, events, employees, savedReports };
return { lookups, units, locations, events, employees, savedReports };
});
if (mode === "events") {
@@ -108,7 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
rows={rows}
total={sumValues(rows)}
recordCount={events.length}
divisions={divisions}
units={units}
locations={locations}
savedReports={savedReports}
/>
@@ -130,12 +134,12 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
division: params.division ?? "",
location: params.location ?? "",
status: params.status ?? "",
employment: params.employment ?? "",
}}
criteria={criteria}
rows={rows}
total={totalForRows(rows, measure)}
recordCount={employees.length}
divisions={divisions}
units={units}
locations={locations}
savedReports={savedReports}
/>

View File

@@ -1,14 +1,15 @@
import { NextResponse, type NextRequest } from "next/server";
import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { todayIso } from "@/lib/format";
import { fmtName, todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
import type { Database, Weekday } from "@/lib/supabase/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
// über die Planstelle und die abgeleitete Berichtslinie.
@@ -35,7 +36,7 @@ export async function GET(request: NextRequest) {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
employment: params.get("employment") ?? undefined,
criteria: parseCriteria((k) => params.get(k)),
};
const statuses = parseStatuses(filters.status);
@@ -48,9 +49,10 @@ export async function GET(request: NextRequest) {
function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
if (!asOf) q = q.where("status", "in", statuses);
return q;
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
}
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
@@ -74,15 +76,17 @@ export async function GET(request: NextRequest) {
}
);
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
const managerName = new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)]));
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
// Export für "Produktion" nur die Bereichsleitung.
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const enriched: EmployeeRow[] = employees.flatMap((e) => {
const placement = placements.get(e.id);
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
return [{
...e,
org_unit_id: orgUnitId,
@@ -117,12 +121,14 @@ function employeeExportColumns(
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
{ header: "Staatsbürgerschaft", get: (e) => e.nationality },
{ header: "Aufenthaltstitel", get: (e) => e.hat_aufenthaltstitel },
{ header: "Aufenthaltstitel bis", get: (e) => e.aufenthaltstitel_bis, kind: "date" },
{ header: "Adresse", get: (e) => e.address },
{ header: "Postleitzahl", get: (e) => e.postal_code },
{ header: "Ort", get: (e) => e.city },
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "E-Mail", get: (e) => e.email },
{ header: "Telefon", get: (e) => e.phone },
{ header: "Private E-Mail", get: (e) => e.email },
{ header: "Private Telefonnummer", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
@@ -142,6 +148,14 @@ function employeeExportColumns(
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade },

View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { parseCriteria } from "@/lib/report-criteria";
import {
aggregateEvents,
aggregateReport,
@@ -69,7 +70,7 @@ export async function GET(request: NextRequest) {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
employment: params.get("employment") ?? undefined,
criteria: parseCriteria((k) => params.get(k)),
asOf,
});
rows = aggregateReport(employees, measure, group, split, lookups, asOf);

View File

@@ -25,7 +25,11 @@ class Rueckabwicklung extends Error {
}
}
export const maxDuration = 120;
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist —
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300
// heben, falls eine Datei mit vielen tausend Zeilen ansteht.
export const maxDuration = 60;
type Antwort = {
ok: boolean;

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useState } from "react";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { SlideOver } from "@/components/ui/SlideOver";
import { actionBadgeStyle } from "@/lib/colors";
import type { AuditChange } from "@/lib/supabase/types";
@@ -33,14 +34,6 @@ const zeitFormat = new Intl.DateTimeFormat("de-AT", {
timeZone: "Europe/Vienna",
});
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
if (text === null || text === "") {
return <span className="text-ink-muted italic">leer</span>;
}
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
}
export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
const [offen, setOffen] = useState(false);
const anzahl = eintrag.changes?.length ?? 0;
@@ -101,29 +94,8 @@ export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
{anzahl > 0 ? (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="py-2 pr-4">Feld</th>
<th className="py-2 pr-4">Vorher</th>
<th className="py-2">Nachher</th>
</tr>
</thead>
<tbody>
{eintrag.changes!.map((c, i) => (
<tr key={i} className="border-b border-border-subtle align-top last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4">
<Wert text={c.vorher} art="vorher" />
</td>
<td className="py-2">
<Wert text={c.nachher} art="nachher" />
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-2">
<AenderungsTabelle changes={eintrag.changes!} />
</div>
) : (
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,

View File

@@ -0,0 +1,70 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { actionBadgeStyle } from "@/lib/colors";
import {
ANSTEHEND_ARTEN,
STANDARD_ZEITRAUM,
ZEITRAEUME,
type AnstehendArt,
type Zeitraum,
} from "@/lib/dashboard-filter";
// Die Auswahl wandert in die Adresse; die Seite baut sich damit neu. Das ist
// hier nötig und nicht bloss ordentlich: ein längerer Zeitraum bringt Zeilen
// ins Spiel, die vorher gar nicht geladen waren.
//
// router.replace statt push, damit der Zurück-Knopf nicht durch jede einzelne
// Filterstellung zurückläuft, und ohne Sprung nach oben — die Karte steht in
// der unteren Hälfte, und dorthin sieht gerade, wer hier klickt.
export function AnstehendFilter({ zeitraum, arten }: { zeitraum: Zeitraum; arten: AnstehendArt[] }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function setzen(key: string, wert: string | null) {
const params = new URLSearchParams(searchParams.toString());
if (wert) params.set(key, wert);
else params.delete(key);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}
function artUmschalten(art: AnstehendArt) {
const alle = ANSTEHEND_ARTEN.map((a) => a.value);
const naechste = arten.includes(art) ? arten.filter((a) => a !== art) : [...arten, art];
// Nichts ausgewählt heisst wieder alles: eine leere Karte ist keine
// Antwort, und der Weg dorthin wäre ein Klick zu weit.
setzen("arten", naechste.length === 0 || naechste.length === alle.length ? null : naechste.join(","));
}
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<SegmentedControl<string>
value={String(zeitraum)}
onChange={(v) => setzen("tage", v === String(STANDARD_ZEITRAUM) ? null : v)}
options={ZEITRAEUME.map((t) => ({ value: String(t), label: `${t} Tage` }))}
/>
<div className="flex flex-wrap items-center gap-1.5">
{ANSTEHEND_ARTEN.map((a) => {
const aktiv = arten.includes(a.value);
return (
<button
key={a.value}
type="button"
onClick={() => artUmschalten(a.value)}
aria-pressed={aktiv}
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
aktiv ? actionBadgeStyle(a.label) : "bg-surface text-ink-muted hover:text-ink"
}`}
>
{a.label}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -34,6 +34,8 @@ type EmployeeDetailProps = {
employee: EmployeeRow;
placement: PlacementInfo | null;
breadcrumb: string;
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
kostenstelle: { code: string; name: string } | null;
manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
@@ -49,7 +51,7 @@ type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "re
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
@@ -106,14 +108,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
</>
)}
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
{isActive && (
{/* Auch bei einem geplanten Eintritt, und dort heisst es anders:
wer nie angetreten ist, „tritt" nicht „aus". Die Person hat
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
geplanter Eintritt stehen. */}
{(isActive || employee.status === "Geplant") && (
<Button
variant="secondary"
size="sm"
onClick={() => setPanel("terminate")}
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
>
<XCircle className="h-4 w-4" /> Austritt
<XCircle className="h-4 w-4" />
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
</Button>
)}
{employee.status === "Ausgetreten" && (
@@ -153,9 +161,10 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
formalManager={formalManager}
directReports={directReports}
breadcrumb={breadcrumb}
kostenstelle={kostenstelle}
/>
)}
{tab === "Historie" && <HistorieTab history={history} />}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
@@ -175,7 +184,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
locationCountry={location?.country}
/>
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
<RehirePanel
open={panel === "rehire"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
</div>
);
}

View File

@@ -104,6 +104,12 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
</option>
))}
</select>
{/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —
dort lässt sich die Auswahl auch exportieren, was der eigentliche
Zweck der Frage war. In dieser Leiste, die vor allem zum Suchen da
ist, wäre er ein Sonderfall unter vielen gleichrangigen. */}
</div>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { Pencil } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { updateHistoryEntry } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { AuditChange } from "@/lib/supabase/types";
// Berichtigen, nicht neu erfassen.
//
// „Daten ändern" schreibt eine *neue* Änderung — richtig, wenn sich etwas
// wirklich geändert hat. Hier geht es um den anderen Fall: der Vorgang
// stimmt, aber der erfasste Wert oder das Datum nicht. Ohne diesen Weg
// stünden in der Akte zwei Einträge für eine Änderung, von denen der erste
// nie stattgefunden hat.
//
// Bearbeitet wird nur das **Nachher**. Das Vorher steht daneben, unveränder-
// lich: es beschreibt, was vor der Änderung galt, und das lässt sich
// nachträglich nicht anders beschliessen.
export function HistorieBearbeiten({
historyId,
employeeId,
bezeichnung,
datum,
changes,
istZukunft,
heute,
nurDatum = false,
}: {
historyId: string;
employeeId: string;
bezeichnung: string;
datum: string;
changes: AuditChange[];
/** Noch nicht wirksam — dann wird der geplante Vorgang berichtigt, nicht der Stand. */
istZukunft: boolean;
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner
* eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts
* verloren. */
heute: string;
/**
* Der Eintritt hat keine Felder, die sich zurücknehmen liessen — nur ein
* Datum, das falsch erfasst sein kann. Daran hängt trotzdem einiges: die
* erste Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das und weist
* verständlich ab.
*/
nurDatum?: boolean;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const [neuesDatum, setNeuesDatum] = useState(datum);
const [werte, setWerte] = useState<Record<string, string>>(() =>
Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""]))
);
const { showToast } = useToast();
const router = useRouter();
// Morgen aus dem Serverdatum, nicht aus der Uhr des Browsers.
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
const etwasGeaendert =
neuesDatum !== datum || changes.some((c) => (werte[c.feld] ?? "") !== (c.nachher ?? ""));
function abbrechen() {
// Beim Schliessen zurück auf den gespeicherten Stand, damit ein zweites
// Öffnen nicht die verworfenen Eingaben zeigt.
setNeuesDatum(datum);
setWerte(Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""])));
setOffen(false);
}
async function speichern() {
// Ein Eintrag bleibt auf seiner Seite der Gegenwart. Eine gelaufene
// Änderung in eine geplante zu verwandeln (oder umgekehrt) hiesse,
// Stammdaten und Vorgang gegenläufig anzupassen — dafür gibt es die
// fachlichen Vorgänge. Die Datenbank weist es ohnehin ab; hier steht es
// nur früher und freundlicher.
// Beim Eintritt gilt das nicht: er darf in der Vergangenheit *und* in der
// Zukunft liegen — ein geplanter Eintritt ist ein gewöhnlicher Fall. Was
// dort zusammenpassen muss, prüft die Datenbank und sagt es verständlich.
if (!nurDatum && istZukunft && neuesDatum <= heute) {
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
return;
}
if (!nurDatum && !istZukunft && neuesDatum > heute) {
showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error");
return;
}
setLaeuft(true);
const ergebnis = await updateHistoryEntry({
history_id: historyId,
employee_id: employeeId,
event_date: neuesDatum,
werte: changes.map((c) => ({ feld: c.feld, nachher: werte[c.feld]?.trim() || null })),
});
setLaeuft(false);
if (ergebnis.success) {
showToast("Eintrag berichtigt.");
setOffen(false);
router.refresh();
} else {
showToast(ergebnis.error ?? "Berichtigen fehlgeschlagen.", "error");
}
}
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-label={`${bezeichnung} vom ${fmtDate(datum)} bearbeiten`}
className="rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<Modal
open={offen}
onClose={abbrechen}
title="Eintrag berichtigen"
widthClassName="max-w-2xl"
footer={
<>
<Button variant="ghost" onClick={abbrechen}>
Abbrechen
</Button>
<Button onClick={speichern} pending={laeuft} disabled={!etwasGeaendert}>
Berichtigen
</Button>
</>
}
>
<p className="text-sm text-ink-body">
<strong className="text-ink">{bezeichnung}</strong>
{nurDatum
? " — der Eintritt selbst bleibt; berichtigt wird nur sein Datum."
: istZukunft
? " — diese Änderung ist noch nicht wirksam. Berichtigt wird, was am Stichtag passieren soll."
: " — was hier stand, war falsch erfasst. Für eine tatsächliche Änderung ist „Daten ändern“ der richtige Weg."}
</p>
<div className="mt-4 max-w-xs">
<TextField
label={nurDatum ? "Eintrittsdatum" : "Wirksam ab"}
type="date"
min={!nurDatum && istZukunft ? morgen : undefined}
max={!nurDatum && !istZukunft ? heute : undefined}
value={neuesDatum}
onChange={setNeuesDatum}
/>
</div>
{nurDatum && (
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
Daran hängt mehr als eine Zahl: die erste Planstellenbesetzung wandert mit, und kein anderes Ereignis darf
vor dem Eintritt liegen. Passt das neue Datum nicht dazu, wird die Änderung mit dem Grund abgewiesen.
</p>
)}
<div className={`mt-5 overflow-x-auto ${nurDatum ? "hidden" : ""}`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="py-2 pr-4">Feld</th>
<th className="py-2 pr-4">Vorher</th>
<th className="py-2">Nachher</th>
</tr>
</thead>
<tbody>
{changes.map((c) => (
<tr key={c.feld} className="border-b border-border-subtle align-middle last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4 text-ink-muted">
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
</td>
<td className="py-2">
<input
type="text"
aria-label={`${c.feld} — neuer Wert`}
value={werte[c.feld] ?? ""}
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
{nurDatum
? "Stammdaten, Historie und Planstellenbesetzung werden gemeinsam nachgezogen."
: istZukunft
? "An den Stammdaten ändert sich jetzt nichts — die Änderung greift erst am Stichtag. Berichtigt wird der geplante Vorgang selbst."
: "Die Stammdaten werden nachgezogen — je Feld gilt dann der jüngste Eintrag, der es trägt. Hat eine spätere Änderung dasselbe Feld erneut gesetzt, bleibt deren Wert stehen."}{" "}
Die Berichtigung selbst steht im Protokoll.
</p>
</Modal>
</>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { deleteHistoryEntry } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { Vorschau } from "@/lib/history";
// Löschen mit Ansage.
//
// Bestätigen heisst hier nicht „Wirklich?" — das beantwortet jede Person nach
// dem dritten Mal blind mit Ja. Der Dialog sagt stattdessen, **was danach
// anders ist**: welches Feld auf welchen Wert zurückgeht, und welches nicht,
// weil eine spätere Änderung es erneut angefasst hat. Wer das liest, merkt
// selbst, ob er den richtigen Eintrag erwischt hat.
export function HistorieLoeschen({
historyId,
employeeId,
bezeichnung,
datum,
vorschau,
istZukunft,
}: {
historyId: string;
employeeId: string;
bezeichnung: string;
datum: string;
vorschau: Vorschau[];
/** Noch nicht wirksam — dann wird der geplante Vorgang entschärft. */
istZukunft: boolean;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const { showToast } = useToast();
const router = useRouter();
const zurueck = vorschau.filter((v) => !v.bleibt);
const bleibt = vorschau.filter((v) => v.bleibt);
// Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die
// sich herausnehmen liessen — sie fällt als Ganzes. Dann ist „Löschen und
// zurücksetzen" das falsche Wort für das, was der Knopf tut.
const ganzerVorgang = istZukunft && vorschau.length === 0;
async function loeschen() {
setLaeuft(true);
const ergebnis = await deleteHistoryEntry({ history_id: historyId, employee_id: employeeId });
setLaeuft(false);
if (ergebnis.success) {
showToast("Eintrag gelöscht.");
setOffen(false);
router.refresh();
} else {
showToast(ergebnis.error ?? "Löschen fehlgeschlagen.", "error");
}
}
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-label={`${bezeichnung} vom ${fmtDate(datum)} löschen`}
className="rounded p-1 text-ink-muted hover:bg-danger-bg hover:text-danger-text
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Modal
open={offen}
onClose={() => setOffen(false)}
title={ganzerVorgang ? "Geplanten Vorgang abbrechen" : "Eintrag löschen"}
footer={
<>
<Button variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button onClick={loeschen} pending={laeuft} className="!bg-danger-solid text-white hover:brightness-110">
{ganzerVorgang ? "Vorgang abbrechen" : "Löschen und zurücksetzen"}
</Button>
</>
}
>
<p className="text-sm text-ink-body">
<strong className="text-ink">{bezeichnung}</strong> {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der
Historie entfernt.
</p>
{ganzerVorgang && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
<p className="mt-1.5 text-sm text-ink-body">
Der Vorgang entfällt ganz — die {bezeichnung} zum {fmtDate(datum)} findet nicht statt. Am Stammsatz wird
das vorgemerkte Datum mit entfernt.
</p>
<p className="mt-2 text-xs text-ink-muted">
An den heutigen Stammdaten ändert sich nichts: der Vorgang war noch nicht wirksam.
</p>
</div>
)}
{istZukunft && vorschau.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
<ul className="mt-1.5 flex flex-col gap-1">
{vorschau.map((v) => (
<li key={v.feld} className="text-sm text-ink-body">
<span className="font-semibold text-ink">{v.feld}</span>{" "}
<span className="text-ink-muted">sollte auf</span> <span className="text-ink">{v.von || "leer"}</span>{" "}
<span className="text-ink-muted">gesetzt werden</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-ink-muted">
An den Stammdaten ändert sich nichts — die Änderung war noch nicht wirksam. Betrifft der geplante Vorgang
noch weitere Felder, läuft er mit diesen weiter.
</p>
</div>
)}
{!istZukunft && zurueck.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird zurückgesetzt</h3>
<ul className="mt-1.5 flex flex-col gap-1">
{zurueck.map((v) => (
<li key={v.feld} className="text-sm text-ink-body">
<span className="font-semibold text-ink">{v.feld}</span>{" "}
<span className="text-ink-muted line-through decoration-ink-muted/40">{v.von || "leer"}</span>{" "}
<span aria-hidden="true">→</span> <span className="text-ink">{v.auf || "leer"}</span>
</li>
))}
</ul>
</div>
)}
{!istZukunft && bleibt.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Bleibt unverändert</h3>
<ul className="mt-1.5 flex flex-col gap-1">
{bleibt.map((v) => (
<li key={v.feld} className="text-sm text-ink-muted">
<span className="font-semibold">{v.feld}</span> — eine spätere Änderung hat dieses Feld erneut
gesetzt, und die gilt weiter.
</li>
))}
</ul>
</div>
)}
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
Der Vorgang wird im Protokoll festgehalten — mit Zeitpunkt, Person und den Werten der gelöschten Zeile. Die
Zeile selbst lässt sich nicht wiederherstellen.
</p>
</Modal>
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { SelectField } from "@/components/ui/Field";
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
import { SelectField, TextField } from "@/components/ui/Field";
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -11,8 +11,25 @@ export type RoleEmploymentValue = {
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
/**
* Nur bedeutsam, solange hasDienstwagen gesetzt ist.
*
* Der Wert bleibt beim Abwählen stehen, statt zurückgesetzt zu werden —
* wer versehentlich klickt und zurückklickt, findet seine Angabe wieder.
* Beim Speichern setzen die Aufrufer ihn auf null, wie es der CHECK
* verlangt.
*/
dienstwagenArt: DienstwagenArt;
isLateraleFuehrung: boolean;
isCLevel: boolean;
/** Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehre. */
hasKuendigungsschutz: boolean;
/**
* Ende des Schutzes — freiwillig. Bei einem Betriebsratsmandat steht es
* fest, bei einer Schwangerschaft nicht; ein Pflichtfeld zwänge dort zu
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
*/
kuendigungsschutzBis: string;
};
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
@@ -78,6 +95,21 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
Dienstwagen
</label>
{/* Nur sichtbar, wenn es einen gibt: eine Antriebsart ohne Fahrzeug
ist keine Angabe, sondern eine Frage ohne Gegenstand — und die
Datenbank weist sie ab. */}
{value.hasDienstwagen && (
<SelectField
label="Antriebsart"
dense
value={value.dienstwagenArt}
onChange={(v) => onChange({ dienstwagenArt: v as DienstwagenArt })}
options={[
{ value: "Verbrenner", label: "Verbrenner" },
{ value: "Elektro", label: "Elektro (E-KFZ)" },
]}
/>
)}
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
Laterale Führung
@@ -86,6 +118,27 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
C-Level
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="checkbox"
checked={value.hasKuendigungsschutz}
onChange={(e) => onChange({ hasKuendigungsschutz: e.target.checked })}
/>
Besonderer Kündigungsschutz
</label>
{/* Wie bei der Antriebsart: erst sichtbar, wenn es einen Gegenstand
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
Weiteres", nicht „vergessen". */}
{value.hasKuendigungsschutz && (
<TextField
label="Geschützt bis"
dense
type="date"
value={value.kuendigungsschutzBis}
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
</div>
</div>
);

View File

@@ -12,10 +12,11 @@ import { CountryPicker } from "@/components/ui/CountryPicker";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries";
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
import { STUNDEN_GRUENDE } from "@/lib/absence";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
@@ -52,15 +53,28 @@ export function DatenAendernPanel({
const svNummerOk =
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
const [nationality, setNationality] = useState(employee.nationality);
const [hatTitel, setHatTitel] = useState(employee.hat_aufenthaltstitel ?? false);
const [titelBis, setTitelBis] = useState(employee.aufenthaltstitel_bis ?? "");
// Die Staatsbürgerschaft entscheidet, ob die Frage überhaupt gestellt wird.
const titelNoetig = brauchtAufenthaltstitel(nationality);
const [address, setAddress] = useState(employee.address ?? "");
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
const [city, setCity] = useState(employee.city ?? "");
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email);
const [email, setEmail] = useState(employee.email ?? "");
const [phone, setPhone] = useState(employee.phone ?? "");
const [notfallName, setNotfallName] = useState(employee.emergency_contact_name ?? "");
const [notfallTelefon, setNotfallTelefon] = useState(employee.emergency_contact_phone ?? "");
const [notfallVerhaeltnis, setNotfallVerhaeltnis] = useState(employee.emergency_contact_relation ?? "");
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
// Der Grund wird erst gefragt, wenn sich die Stunden tatsächlich ändern —
// sonst stünde bei jeder Adressänderung eine Frage im Weg, die niemand
// gestellt hat.
const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]);
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
@@ -70,6 +84,9 @@ export function DatenAendernPanel({
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: employee.is_betriebsrat ?? false,
hasDienstwagen: employee.has_dienstwagen ?? false,
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
});
@@ -99,6 +116,12 @@ export function DatenAendernPanel({
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
return;
}
// Name und Nummer gehören zusammen — die Datenbank weist eines ohne das
// andere ab, und die Meldung dort erklärt es nicht.
if (Boolean(notfallName.trim()) !== Boolean(notfallTelefon.trim())) {
showToast("Beim Notfallkontakt braucht es Name und Telefonnummer — oder beides leer.", "error");
return;
}
setPending(true);
const result = await changeEmployeeData({
employee_id: employee.id,
@@ -112,12 +135,21 @@ export function DatenAendernPanel({
birth_date: birthDate,
sv_nummer: svNummer,
nationality,
// Wechselt die Staatsbürgerschaft in den Freizügigkeitsraum, fällt
// der Titel weg — sonst bliebe er als Rest an einer Person hängen,
// die ihn nicht mehr braucht. Die Datenbank prüft diese Kopplung
// bewusst nicht (siehe Migration), also gehört sie hierher.
hat_aufenthaltstitel: titelNoetig ? hatTitel : false,
aufenthaltstitel_bis: titelNoetig && hatTitel ? titelBis : "",
address,
postal_code: postalCode,
city,
address_country: addressCountry,
email,
phone,
emergency_contact_name: notfallName.trim(),
emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.trim(),
},
contract: {
employment_type: employmentType,
@@ -131,6 +163,19 @@ export function DatenAendernPanel({
work_days: role.workDays,
is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen,
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
has_kuendigungsschutz: role.hasKuendigungsschutz,
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar
// zurück, obwohl niemand die Stunden angefasst hat.
...(stundenGeaendert
? {
teilzeit_art: stundenGrund === "Vertragliche Stundenänderung" ? "" : stundenGrund,
teilzeit_bis: stundenGrund === "Vertragliche Stundenänderung" ? "" : teilzeitBis,
}
: {}),
is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel,
},
@@ -197,6 +242,34 @@ export function DatenAendernPanel({
<CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
)}
</Field>
{/* Nur wo er verlangt ist. Bei Freizügigkeit — EU, EWR, Schweiz —
wäre die Frage gegenstandslos, und ein „Nein" im Formular
sähe aus wie eine Auskunft. */}
{titelNoetig && (
<>
<SelectField
label="Aufenthaltstitel"
dense
value={hatTitel ? "ja" : "nein"}
onChange={(v) => setHatTitel(v === "ja")}
options={[
{ value: "nein", label: "Nein" },
{ value: "ja", label: "Ja" },
]}
hint="Bei Staatsbürgerschaften ausserhalb von EU, EWR und Schweiz."
/>
{hatTitel && (
<TextField
label="Aufenthaltstitel gültig bis"
dense
type="date"
value={titelBis}
onChange={setTitelBis}
hint="Optional. Leer lassen, wenn unbefristet."
/>
)}
</>
)}
<TextField label="Adresse (Straße und Hausnummer)" dense value={address} onChange={setAddress} />
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
@@ -208,6 +281,24 @@ export function DatenAendernPanel({
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
</div>
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
er nicht zwischen den Adressfeldern der Person untergehen. */}
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
<SelectField
label="Verhältnis"
dense
value={notfallVerhaeltnis}
onChange={setNotfallVerhaeltnis}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
</fieldset>
</div>
<div>
@@ -232,6 +323,26 @@ export function DatenAendernPanel({
disabled={employmentType === "Vollzeit"}
onChange={setWeeklyHours}
/>
{stundenGeaendert && (
<SelectField
label="Grund der Stundenänderung"
dense
value={stundenGrund}
onChange={setStundenGrund}
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
hint="Steht danach am Profil und lässt sich auswerten."
/>
)}
{stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && (
<TextField
label="Teilzeit bis"
dense
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
<SelectField
label="Vertragsart"
dense

View File

@@ -8,8 +8,8 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { ABSENCE_TYPES, absenceLabel } from "@/lib/absence";
import { fmtDate } from "@/lib/format";
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
import { fmtDate, fmtName } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -33,7 +33,18 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
const [returnDate, setReturnDate] = useState("");
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
const [weeklyHours, setWeeklyHours] = useState("20");
// Warum weniger Stunden: Wiedereingliederungs- oder Elternteilzeit. Beide
// beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb
// steht die Frage hier und nicht in einem zweiten Vorgang danach.
const [reduktionsgrund, setReduktionsgrund] = useState<string>("");
const [teilzeitBis, setTeilzeitBis] = useState("");
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
// hierzulande schreibt.
const stundenText = String(employee.weekly_hours).replace(".", ",");
// Absichtlich leer statt vorbelegt: eine Zahl, die schon dasteht, wird
// bestätigt statt erfasst. Die reduzierten Stunden stehen in einer
// Vereinbarung, und die muss jemand ablesen.
const [weeklyHours, setWeeklyHours] = useState("");
const diffDays =
isOnKarenz && employee.karenz_return_date && newReturnDate
@@ -89,9 +100,15 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
showToast("Bitte Rückkehrdatum angeben.", "error");
return;
}
if (employmentMode === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
return;
if (employmentMode === "Teilzeit") {
if (!weeklyHours.trim()) {
showToast("Bitte die reduzierten Wochenstunden erfassen.", "error");
return;
}
if (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5) {
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
return;
}
}
setPending(true);
const result = await recordKarenzReturn({
@@ -99,6 +116,8 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
return_date: returnDate,
employment_mode: employmentMode,
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined,
});
setPending(false);
if (result.success) {
@@ -115,7 +134,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
open={open}
onClose={onClose}
title={isOnKarenz ? "Langzeitabwesenheit verwalten" : "Langzeitabwesenheit erfassen"}
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
@@ -191,22 +210,46 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
value={employmentMode}
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
options={[
{ value: "unverändert", label: "unverändert" },
{ value: "Vollzeit", label: "Vollzeit (38,5h)" },
{ value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
// Die Stunden, die zuletzt gearbeitet wurden, stehen in der
// Beschriftung. „unverändert" allein zwang dazu, in der
// Akte nachzusehen, worauf man sich da einlässt.
{ value: "unverändert", label: `Wie vor Abwesenheit (${stundenText} h)` },
{ value: "Teilzeit", label: "Reduziert" },
]}
/>
{employmentMode === "Teilzeit" && (
<TextField
label="Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
hint="Muss unter 38,5 liegen."
/>
<>
<TextField
label="Reduzierte Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
placeholder={`weniger als ${stundenText}`}
hint="Muss unter 38,5 liegen."
/>
<SelectField
label="Grund der Reduktion"
value={reduktionsgrund}
onChange={setReduktionsgrund}
options={[
{ value: "", label: "Ohne besonderen Grund" },
...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })),
]}
hint="Steht danach am Profil und lässt sich auswerten."
/>
{reduktionsgrund && (
<TextField
label="Teilzeit bis"
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
</>
)}
</>
)}

View File

@@ -8,6 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database, PaygradeType } from "@/lib/supabase/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -55,7 +56,7 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
open={open}
onClose={onClose}
title="Beförderung"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>

View File

@@ -1,30 +1,77 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import { rehireEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import { fmtDate, fmtName } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
// Eine Wiedereinstellung braucht eine Planstelle — genau wie eine
// Neueinstellung.
//
// Die alte Stelle ist dafür kein Ersatz: sie kann inzwischen besetzt,
// ausgelaufen oder ganz entfallen sein. `rehire_employee` verlangte die
// Angabe deshalb schon immer und wies den Aufruf sonst ab; nur schickte das
// Formular sie nie mit, sodass jede Wiedereinstellung an einer Meldung
// scheiterte, die sich im Dialog gar nicht beheben liess.
export function RehirePanel({
open,
onClose,
employee,
openPositions,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
openPositions: OpenPositionResolved[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [rehireDate, setRehireDate] = useState("");
const [positionId, setPositionId] = useState("");
const [pending, setPending] = useState(false);
const options = useMemo(
() =>
openPositions
.slice()
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
[openPositions]
);
const selected = openPositions.find((p) => p.id === positionId);
// Die Datenbank weist eine Besetzung ausserhalb der Gültigkeit ab. Das hier
// nimmt die Meldung vorweg, solange sie noch etwas nützt — im Dialog, mit
// beiden Daten sichtbar.
const ausserhalb =
selected && rehireDate
? rehireDate < selected.valid_from
? `Diese Planstelle gilt erst ab ${fmtDate(selected.valid_from)}.`
: selected.valid_to && rehireDate >= selected.valid_to
? `Diese Planstelle gilt nur bis ${fmtDate(selected.valid_to)}.`
: null
: null;
async function handleSubmit() {
if (!rehireDate) {
showToast("Bitte ein Wiedereintrittsdatum angeben.", "error");
if (!rehireDate || !positionId) {
showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error");
return;
}
setPending(true);
const result = await rehireEmployee({ employee_id: employee.id, rehire_date: rehireDate });
const result = await rehireEmployee({
employee_id: employee.id,
rehire_date: rehireDate,
position_id: positionId,
});
setPending(false);
if (result.success) {
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
@@ -40,13 +87,13 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
open={open}
onClose={onClose}
title="Wiedereinstellung"
subtitle={`${employee.first_name} ${employee.last_name}`}
subtitle={fmtName(employee.first_name, employee.last_name)}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
<Button onClick={handleSubmit} pending={pending} disabled={Boolean(ausserhalb)}>
Wiedereinstellen
</Button>
</>
@@ -58,7 +105,42 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
<p className="mt-1 text-ink">{employee.job_title}</p>
<p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
</div>
<TextField label="Wiedereintritt am" required type="date" value={rehireDate} onChange={setRehireDate} />
{options.length === 0 ? (
<p className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine
Planstelle anlegen oder eine bestehende freimachen.
</p>
) : (
<>
<SelectField
label="Planstelle"
required
value={positionId}
onChange={setPositionId}
placeholder="Bitte wählen…"
options={options}
/>
{selected && (
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
<div className="font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
<div className="mt-1 text-xs text-ink-muted">
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
{selected.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""}
{selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""}
</div>
</div>
)}
{ausserhalb && (
<p role="alert" className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
{ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen.
</p>
)}
</>
)}
</div>
</SlideOver>
);

View File

@@ -8,10 +8,22 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
import { fmtDate, fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
const NO_SHOW = "No Show";
const EXIT_REASONS = [
"Einvernehmliche Auflösung",
"Kündigung AN",
"Kündigung AG",
"Befristungsablauf",
"Pensionierung",
"Entlassung",
NO_SHOW,
];
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
type TerminatePanelProps = {
@@ -25,21 +37,40 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
const { showToast } = useToast();
const router = useRouter();
const [exitDate, setExitDate] = useState("");
const [reason, setReason] = useState(EXIT_REASONS[0]);
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
// übrigen zu verstellen.
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
const [note, setNote] = useState("");
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
const [pending, setPending] = useState(false);
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
// an dem die Person hätte anfangen sollen. Die Datenbank setzt es ohnehin
// so; hier steht es sichtbar, damit niemand ein Datum eintippt, das dann
// stillschweigend übergangen wird.
const istNoShow = reason === NO_SHOW;
const wirksamesDatum = istNoShow ? employee.entry_date : exitDate;
async function handleSubmit() {
if (!exitDate) {
if (!wirksamesDatum) {
showToast("Bitte ein Austrittsdatum angeben.", "error");
return;
}
setPending(true);
const result = await terminateEmployee({ employee_id: employee.id, exit_date: exitDate, exit_reason: reason, note });
const result = await terminateEmployee({
employee_id: employee.id,
exit_date: wirksamesDatum,
exit_reason: reason,
note,
});
setPending(false);
if (result.success) {
showToast(`Austritt für ${employee.first_name} ${employee.last_name} erfasst.`);
showToast(
istNoShow
? `${employee.first_name} ${employee.last_name} ist nicht angetreten.`
: `Austritt für ${employee.first_name} ${employee.last_name} erfasst.`
);
router.refresh();
onClose();
} else {
@@ -51,8 +82,8 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
<SlideOver
open={open}
onClose={onClose}
title="Austritt"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
title={istNoShow ? "Nicht angetreten" : "Austritt"}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
@@ -65,20 +96,54 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
}
>
<div className="flex flex-col gap-4">
{/* Zuerst, und nicht zu übersehen: bei besonderem Kündigungsschutz
gelten eigene Regeln, bevor beendet werden darf. Die Anwendung
entscheidet das nicht — sie darf es aber auch nicht verschweigen,
und im Vertragsblatt nachzusehen ist genau der Schritt, den man
unter Zeitdruck auslässt. */}
{employee.has_kuendigungsschutz && (
<div className="rounded border border-danger-solid bg-danger-bg px-3 py-2 text-sm font-semibold text-danger-text">
Achtung: besonderer Kündigungsschutz
{employee.kuendigungsschutz_bis ? ` bis ${fmtDate(employee.kuendigungsschutz_bis)}` : " (Ende nicht erfasst)"}.
<span className="block font-normal">
Vor einer Beendigung ist zu prüfen, ob sie zulässig ist — je nach Grund braucht es eine Zustimmung des
Betriebsrats oder des Gerichts.
</span>
</div>
)}
{directReportCount > 0 && (
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
</div>
)}
<TextField label="Austrittsdatum" required type="date" value={exitDate} onChange={setExitDate} />
<SelectField
label="Beendigungsart"
value={reason}
onChange={setReason}
options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
/>
<TextField
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
required
type="date"
value={wirksamesDatum}
disabled={istNoShow}
onChange={setExitDate}
hint={
istNoShow
? "Wer nie angetreten ist, scheidet am Tag seines Eintritts aus. Damit gibt es keinen Tag, an dem die Person als beschäftigt zählt."
: undefined
}
/>
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
<fieldset>
{istNoShow && (
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
Die Planstelle wird wieder frei und gilt als nie besetzt. In allen Auswertungen zählt die Person an keinem
Stichtag als beschäftigt.
</p>
)}
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
<fieldset hidden={istNoShow}>
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
<div className="flex flex-col gap-2">
{CHECKLIST_ITEMS.map((item, i) => (

View File

@@ -9,6 +9,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -68,7 +69,7 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
open={open}
onClose={onClose}
title="Versetzung"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>

View File

@@ -1,35 +1,208 @@
"use client";
import { useState } from "react";
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { TextField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate, todayIso } from "@/lib/format";
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
export function HistorieTab({ history }: { history: HistoryRow[] }) {
// Die Geschichte einer Person — aufklappbar bis auf die Werte, filterbar, und
// dort, wo ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar.
//
// Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich
// zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was
// vorher dort stand. Die alte Adresse lag allein im Protokoll, und das ist
// eine andere Seite, nach Zeitpunkt sortiert statt nach Person — man hätte
// also erst wissen müssen, wonach man sucht.
//
// Aufgeklappt wird mit <details>, nicht mit einem Zustand im Browser: die
// Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im
// Ausdruck sichtbar, und es braucht kein Skript dafür.
//
// Die Knöpfe erscheinen nur an Einträgen, wo sie etwas bewirken können. An
// allen anderen steht stattdessen der Grund — leise, aber lesbar. Ein Knopf,
// der erst nach dem Klick sagt „geht nicht", wäre eine Falle; ein fehlender
// Knopf ohne Erklärung wäre ein Rätsel.
type Sicht = "alle" | "anstehend" | "erledigt";
const SICHTEN: { value: Sicht; label: string }[] = [
{ value: "alle", label: "Alle" },
{ value: "anstehend", label: "Anstehend" },
{ value: "erledigt", label: "Gelaufen" },
];
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
const today = todayIso();
const [sicht, setSicht] = useState<Sicht>("alle");
const [von, setVon] = useState("");
const [bis, setBis] = useState("");
// Ereignistypen, die in dieser Akte überhaupt vorkommen — eine Auswahl aus
// elf Typen, von denen zehn nie auftauchen, wäre nur Suchaufwand.
const vorhandeneTypen = [...new Set(history.map((h) => h.event_type))];
const [typen, setTypen] = useState<Set<string>>(new Set());
const gefiltert = history.filter((h) => {
if (sicht === "anstehend" && h.event_date <= today) return false;
if (sicht === "erledigt" && h.event_date > today) return false;
if (von && h.event_date < von) return false;
if (bis && h.event_date > bis) return false;
if (typen.size > 0 && !typen.has(h.event_type)) return false;
return true;
});
const anstehend = history.filter((h) => h.event_date > today).length;
const eingeschraenkt = sicht !== "alle" || Boolean(von) || Boolean(bis) || typen.size > 0;
function typUmschalten(typ: string) {
setTypen((prev) => {
const next = new Set(prev);
if (next.has(typ)) next.delete(typ);
else next.add(typ);
return next;
});
}
function zuruecksetzen() {
setSicht("alle");
setVon("");
setBis("");
setTypen(new Set());
}
if (history.length === 0) {
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
}
return (
<ul className="flex flex-col divide-y divide-border">
{history.map((h) => {
const isFuture = h.event_date > today;
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>{h.event_type}</span>
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
{isFuture && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
</span>
)}
</div>
<p className="mt-1 text-sm text-ink">{h.description}</p>
</li>
);
})}
</ul>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 rounded border border-border bg-surface px-3 py-2.5">
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl<Sicht> value={sicht} onChange={setSicht} options={SICHTEN} />
{/* Was noch kommt, ist der häufigste Grund, hier hereinzusehen —
deshalb steht die Zahl da, auch ohne dass jemand filtert. */}
{anstehend > 0 && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
{anstehend} anstehend
</span>
)}
<div className="ml-auto flex items-end gap-2">
<TextField label="Von" dense type="date" value={von} onChange={setVon} className="w-40" />
<TextField label="Bis" dense type="date" value={bis} onChange={setBis} className="w-40" />
</div>
</div>
{vorhandeneTypen.length > 1 && (
<div className="flex flex-wrap items-center gap-1.5">
{vorhandeneTypen.map((typ) => (
<button
key={typ}
type="button"
onClick={() => typUmschalten(typ)}
aria-pressed={typen.has(typ)}
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
}`}
>
{typ}
</button>
))}
{eingeschraenkt && (
<button
type="button"
onClick={zuruecksetzen}
className="ml-auto rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Filter zurücksetzen
</button>
)}
</div>
)}
</div>
{gefiltert.length === 0 ? (
<p className="text-sm text-ink-muted">
Kein Eintrag passt zu dieser Auswahl. {history.length} {history.length === 1 ? "Eintrag" : "Einträge"} sind
vorhanden.
</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{gefiltert.map((h) => {
const isFuture = h.event_date > today;
const changes = h.changes ?? [];
// Die ganze Historie mitgeben, nicht die gefilterte: ob eine
// Abwesenheit gelöscht werden darf, hängt an einer späteren
// Rückkehr — auch wenn die gerade ausgeblendet ist.
const loeschbar = darfKorrigiertWerden(h, today, history);
const bearbeitbar = darfBearbeitetWerden(h, today, history);
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
{isFuture && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
</span>
)}
<span className="ml-auto flex items-center gap-0.5">
{bearbeitbar.erlaubt && (
<HistorieBearbeiten
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
changes={changes}
istZukunft={isFuture}
heute={today}
nurDatum={h.event_type === "Eintritt"}
/>
)}
{loeschbar.erlaubt && (
<HistorieLoeschen
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
vorschau={loeschVorschau(h, history)}
istZukunft={isFuture}
/>
)}
</span>
</div>
<p className="mt-1 text-sm text-ink">{h.description}</p>
{changes.length > 0 && (
<details className="group mt-1.5">
<summary
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
›
</span>
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
</summary>
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
<AenderungsTabelle changes={changes} />
</div>
{!loeschbar.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{loeschbar.grund}</p>}
</details>
)}
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { Network } from "lucide-react";
import Link from "next/link";
import { Avatar } from "@/components/ui/Avatar";
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { fmtName } from "@/lib/format";
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
@@ -12,15 +13,39 @@ type OrganisationTabProps = {
formalManager: MiniEmployee | null;
directReports: MiniEmployee[];
breadcrumb: string;
/**
* Die Kostenstelle der Planstelle, auf der die Person heute sitzt — nicht
* ihre eigene: sie kontiert dorthin, wo ihr Sitz kontiert ist. Fehlt sie,
* hat die Person keine laufende Besetzung (geplanter Eintritt, Austritt).
*/
kostenstelle: { code: string; name: string } | null;
};
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) {
export function OrganisationTab({
employeeId,
manager,
formalManager,
directReports,
breadcrumb,
kostenstelle,
}: OrganisationTabProps) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-ink-muted">Kostenstelle</h3>
<p className="mt-1 text-sm text-ink">
{kostenstelle ? (
<>
<span className="font-semibold tabular-nums">{kostenstelle.code}</span>
<span className="text-ink-body"> · {kostenstelle.name}</span>
</>
) : (
<span className="text-ink-muted">Keine laufende Planstellenbesetzung</span>
)}
</p>
</div>
{/* ?focus= drives the same highlight/auto-expand path the org chart
search already uses, so the person is unfolded and centred on
@@ -37,7 +62,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
</h3>
{formalManager && (
<p className="mb-2 text-xs text-ink-muted">
Zuständig ist {formalManager.first_name} {formalManager.last_name}; während der Abwesenheit übernimmt die nächste
Zuständig ist {fmtName(formalManager.first_name, formalManager.last_name)}; während der Abwesenheit übernimmt die nächste
besetzte Ebene.
</p>
)}
@@ -46,7 +71,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
<div>
<div className="text-sm font-semibold text-ink">
{manager.first_name} {manager.last_name}
{fmtName(manager.first_name, manager.last_name)}
</div>
<div className="text-xs text-ink-muted">{manager.job_title}</div>
</div>
@@ -65,7 +90,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
<Avatar firstName={r.first_name} lastName={r.last_name} />
<div>
<div className="text-sm font-semibold text-ink">
{r.first_name} {r.last_name}
{fmtName(r.first_name, r.last_name)}
</div>
<div className="text-xs text-ink-muted">{r.job_title}</div>
</div>

View File

@@ -1,4 +1,5 @@
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { brauchtAufenthaltstitel } from "@/lib/countries";
import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
@@ -6,27 +7,56 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
function formatAddress(employee: EmployeeRow): string {
const cityLine = [employee.postal_code, employee.city].filter(Boolean).join(" ");
return [employee.address, cityLine].filter(Boolean).join(", ") || "–";
}
export function StammdatenTab({ employee, location, dependents }: { employee: EmployeeRow; location?: Location; dependents: Dependent[] }) {
// Defensive against a DB that hasn't received the title_prefix/title_suffix
// migration yet — select("*") simply omits unknown columns, so these can
// be undefined rather than the empty array the column default implies.
const titles = [...(employee.title_prefix ?? []), ...(employee.title_suffix ?? [])];
const prefixe = employee.title_prefix ?? [];
const suffixe = employee.title_suffix ?? [];
// Feld für Feld dieselbe Liste wie im Abschnitt „Person" von „Daten
// ändern", in derselben Reihenfolge.
//
// Vorher fasste die Anzeige zusammen: Titel in einer Zeile, Adresse mit
// Postleitzahl und Ort verschmolzen, Vor- und Nachname gar nicht — die
// standen nur in der Kopfzeile. Wer eine Angabe prüfen wollte, musste den
// Änderungsdialog öffnen, um sie überhaupt zu sehen, und stand dann schon
// in einem Formular. Was sich ändern lässt, soll sich auch ansehen lassen.
const rows: [string, string][] = [
["Titel", titles.length > 0 ? titles.join(", ") : "–"],
["Personalnummer", String(employee.personnel_number)],
["Vorname", employee.first_name],
["Nachname", employee.last_name],
["Titel (vorangestellt)", prefixe.length > 0 ? prefixe.join(", ") : "–"],
["Titel (nachgestellt)", suffixe.length > 0 ? suffixe.join(", ") : "–"],
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
["SV-Nummer", employee.sv_nummer ?? "–"],
["Staatsbürgerschaft", employee.nationality],
["E-Mail", employee.email],
["Telefon", employee.phone ?? "–"],
["Standort", location ? `${location.name} (${location.country})` : "–"],
["Adresse", formatAddress(employee)],
// Nur, wo er verlangt ist. Bei einer österreichischen Staatsbürger-
// schaft wäre die Zeile „Aufenthaltstitel: Nein" keine Auskunft,
// sondern eine Frage, die sich nicht stellt.
...(brauchtAufenthaltstitel(employee.nationality)
? ([
[
"Aufenthaltstitel",
employee.hat_aufenthaltstitel
? employee.aufenthaltstitel_bis
? `Ja, bis ${fmtDate(employee.aufenthaltstitel_bis)}`
: "Ja (unbefristet oder nicht erfasst)"
: "Nein",
],
] as [string, string][])
: []),
["Adresse", employee.address ?? "–"],
["Postleitzahl", employee.postal_code ?? "–"],
["Ort", employee.city ?? "–"],
["Land", employee.address_country ?? "–"],
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
["Private E-Mail", employee.email ?? "–"],
["Private Telefonnummer", employee.phone ?? "–"],
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man
// ihn für den Wohnort halten könnte.
["Standort", location ? `${location.name} (${location.country})` : "–"],
];
return (
<div className="flex flex-col gap-6">
@@ -38,7 +68,46 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
</div>
))}
</dl>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
{/* Eigener Abschnitt statt einer Zelle im Raster, und unterhalb der
Angehörigen: beides sind Personen im Umfeld, und der Notfallkontakt
ist die Ausnahme davon — deshalb steht er zuletzt, nicht dazwischen.
Im Ernstfall greift jemand in Eile danach; dann muss die Nummer
sofort zu finden sein und wählbar. */}
<div className="border-t border-border pt-6">
<h3 className="mb-3 text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
{employee.emergency_contact_name ? (
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Name</dt>
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_name}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Telefon</dt>
<dd className="mt-1 text-sm font-semibold text-ink">
{employee.emergency_contact_phone ? (
<a
href={`tel:${employee.emergency_contact_phone.replace(/\s/g, "")}`}
className="rounded hover:text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{employee.emergency_contact_phone}
</a>
) : (
"–"
)}
</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Verhältnis</dt>
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_relation || "–"}</dd>
</div>
</dl>
) : (
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
)}
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
@@ -13,12 +14,11 @@ const PAYGRADE_LABELS: Record<string, string> = {
};
export function VertragTab({ employee }: { employee: EmployeeRow }) {
const flags = [
employee.is_betriebsrat && "Betriebsrat",
employee.has_dienstwagen && "Dienstwagen",
employee.is_laterale_fuehrung && "Laterale Führung",
employee.is_c_level && "C-Level",
].filter(Boolean);
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
// durch Kommas getrennt — und ein Gedankenstrich, wenn nichts zutraf. Damit
// liess sich nicht ablesen, ob jemand *keinen* Dienstwagen hat oder ob
// niemand die Frage je beantwortet hat. Jedes Merkmal steht jetzt für sich,
// mit Ja oder Nein, wie jede andere Zeile auf diesem Blatt auch.
const rows: [string, string][] = [
["Eintrittsdatum", fmtDate(employee.entry_date)],
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
@@ -29,7 +29,32 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
["Arbeitstage", employee.work_days?.join(", ") || "–"],
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
// war die Frage dahinter, seit E-Fahrzeuge getrennt zu führen sind.
["Dienstwagen", employee.has_dienstwagen ? dienstwagenLabel(employee.dienstwagen_art) : "Nein"],
["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"],
["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"],
["C-Level", employee.is_c_level ? "Ja" : "Nein"],
// Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der
// Schutz noch läuft, und genau danach fragt man.
// Die Teilzeitvariante gehört neben die Stunden: sie erklärt, warum sie
// sind, wie sie sind.
[
"Teilzeitvariante",
employee.teilzeit_art
? employee.teilzeit_bis
? `${employee.teilzeit_art} bis ${fmtDate(employee.teilzeit_bis)}`
: `${employee.teilzeit_art} (Ende offen)`
: "–",
],
[
"Besonderer Kündigungsschutz",
employee.has_kuendigungsschutz
? employee.kuendigungsschutz_bis
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
: "Ja (Ende offen)"
: "Nein",
],
];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -2,20 +2,22 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { hireEmployee } from "@/actions/employees";
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { StepAngehoerige } from "./StepAngehoerige";
import { StepNotfallkontakt } from "./StepNotfallkontakt";
import { StepPerson } from "./StepPerson";
import { StepPosition } from "./StepPosition";
import { StepSummary } from "./StepSummary";
import { StepVertrag } from "./StepVertrag";
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
const STEP_LABELS = ["Person", "Position", "Vertrag", "Zusammenfassung"];
const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
type HireWizardProps = {
open: boolean;
@@ -61,13 +63,31 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
// Schritts, nach allen Eingaben.
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) &&
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
// freiwillig, seit die Spalte NULL zulässt.
Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
svNummerOk,
Boolean(draft.positionId && draft.besetzung),
// Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig
// sein, sonst scheitert sie erst nach dem Anlegen der Person, und die
// steht dann schon in der Datenbank.
draft.angehoerige.every(
(a) =>
a.firstName.trim() &&
a.lastName.trim() &&
a.birthDate &&
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
),
Boolean(draft.entryDate && draft.workDays.length > 0),
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
true,
][step];
/** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */
const letzterSchritt = STEP_LABELS.length - 1;
async function handleSaveDraft() {
const result = await saveHireDraft({ id: draftId, step, data: draft });
if (result.success) {
@@ -83,6 +103,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
if (!selectedPosition || !draft.besetzung) return;
setSubmitting(true);
const result = await hireEmployee({
personnel_number: Number(draft.personnelNumber),
first_name: draft.firstName,
last_name: draft.lastName,
title_prefix: draft.titlePrefix,
@@ -90,7 +111,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
gender: draft.gender,
birth_date: draft.birthDate,
sv_nummer: draft.svNummer || undefined,
email: draft.email.trim(),
email: draft.email.trim() || undefined,
phone: draft.phone || undefined,
position_id: draft.positionId,
location_id: draft.locationId,
@@ -106,17 +127,57 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
work_days: draft.workDays,
is_betriebsrat: draft.isBetriebsrat,
has_dienstwagen: draft.hasDienstwagen,
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
// sonst nicht zu.
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
has_kuendigungsschutz: draft.hasKuendigungsschutz,
// Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu.
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
is_laterale_fuehrung: draft.isLateraleFuehrung,
is_c_level: draft.isCLevel,
});
setSubmitting(false);
if (result.success) {
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
if (draftId) await deleteHireDraft(draftId);
router.refresh();
onClose();
} else {
if (!result.success || !result.employeeId) {
setSubmitting(false);
showToast(result.error ?? "Fehler beim Anlegen.", "error");
return;
}
// Angehörige erst jetzt: add_employee_dependent braucht die Kennung, und
// die entsteht mit der Einstellung.
//
// Damit hängen sie ausserhalb der Transaktion, in der die Person
// entsteht. Scheitert eine, ist die Person trotzdem angelegt — deshalb
// wird nicht stillschweigend weitergemacht, sondern genau gesagt, wer
// fehlt. Nachtragen geht in der Personalakte.
const gescheitert: string[] = [];
for (const a of draft.angehoerige) {
const r = await addEmployeeDependent({
employee_id: result.employeeId,
first_name: a.firstName.trim(),
last_name: a.lastName.trim(),
relationship: a.relationship,
birth_date: a.birthDate,
sv_nummer: a.svNummer.trim() || undefined,
effective_date: draft.entryDate,
});
if (!r.success) gescheitert.push(`${a.firstName} ${a.lastName}`.trim());
}
setSubmitting(false);
if (draftId) await deleteHireDraft(draftId);
router.refresh();
onClose();
if (gescheitert.length > 0) {
showToast(
`${draft.firstName} ${draft.lastName} wurde eingestellt, aber ${gescheitert.join(", ")} konnte nicht als Angehörige:r angelegt werden — bitte in der Personalakte nachtragen.`,
"error"
);
} else {
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
}
}
@@ -142,12 +203,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
Zurück
</Button>
)}
{step < 3 && (
{step < letzterSchritt && (
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
Weiter
</Button>
)}
{step === 3 && (
{step === letzterSchritt && (
<Button onClick={handleSubmit} pending={submitting}>
Anlegen
</Button>
@@ -181,8 +242,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
{step === 2 && <StepVertrag draft={draft} update={update} />}
{step === 3 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
{step === 2 && <StepAngehoerige draft={draft} update={update} />}
{step === 3 && <StepVertrag draft={draft} update={update} />}
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
</Modal>
);
}

View File

@@ -0,0 +1,113 @@
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { fmtDate } from "@/lib/format";
import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
import type { RelationshipType } from "@/lib/supabase/types";
import type { HireDraftAngehoerige, HireDraftData } from "./types";
const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
// Angehörige im Assistenten, obwohl es die Person noch nicht gibt.
//
// Sie werden hier gesammelt und erst nach dem Anlegen angehängt — die
// Datenbankfunktion braucht eine Kennung, und die entsteht mit der
// Einstellung. Der Preis dafür steht in HireWizard: schlägt eine der
// Ergänzungen fehl, ist die Person trotzdem angelegt, und die Meldung sagt
// das dann auch.
//
// Freiwillig: die meisten Einstellungen kommen ohne aus, und wer später
// etwas nachträgt, findet denselben Dialog in der Personalakte.
export function StepAngehoerige({
draft,
update,
}: {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
}) {
const liste = draft.angehoerige;
function setze(index: number, patch: Partial<HireDraftAngehoerige>) {
update({ angehoerige: liste.map((a, i) => (i === index ? { ...a, ...patch } : a)) });
}
function hinzufuegen() {
update({
angehoerige: [...liste, { firstName: "", lastName: draft.lastName, relationship: "Kind", birthDate: "", svNummer: "" }],
});
}
return (
<div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-ink-muted">
Angehörige sind freiwillig und lassen sich jederzeit in der Personalakte nachtragen. Der Nachname ist mit dem
der einzustellenden Person vorbelegt — überschreibbar.
</p>
{liste.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Angehörigen erfasst.</p>
) : (
<div className="flex flex-col gap-3">
{liste.map((a, i) => {
// Dieselbe Prüfung wie bei der Person selbst: Prüfziffer und
// Geburtsdatum müssen zusammenpassen. Hier schon, damit der
// Fehler nicht erst nach dem Anlegen auftaucht — dann existiert
// die Person bereits und die Angehörige fehlt.
const svFehler = a.svNummer.trim() ? validateSvnr(a.svNummer, a.birthDate || null) : null;
return (
<fieldset key={i} className="rounded-md border border-border p-3">
<legend className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">
{a.firstName || a.lastName ? `${a.firstName} ${a.lastName}`.trim() : `Angehörige:r ${i + 1}`}
{a.birthDate && <span className="font-normal normal-case">· {fmtDate(a.birthDate)}</span>}
</legend>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" dense required value={a.firstName} onChange={(v) => setze(i, { firstName: v })} />
<TextField label="Nachname" dense required value={a.lastName} onChange={(v) => setze(i, { lastName: v })} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SelectField
label="Verhältnis"
dense
required
value={a.relationship}
onChange={(v) => setze(i, { relationship: v as RelationshipType })}
options={VERHAELTNIS.map((r) => ({ value: r, label: r }))}
/>
<TextField label="Geburtsdatum" dense required type="date" value={a.birthDate} onChange={(v) => setze(i, { birthDate: v })} />
</div>
<TextField
label="SV-Nummer"
dense
value={a.svNummer}
onChange={(v) => setze(i, { svNummer: v })}
error={svFehler ? svnrErrorMessage(svFehler) : undefined}
hint={!svFehler && a.svNummer.trim() ? formatSvnr(a.svNummer) : undefined}
/>
</div>
<div className="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => update({ angehoerige: liste.filter((_, j) => j !== i) })}
className="!px-1 text-danger-text hover:!bg-transparent hover:underline"
>
<Trash2 className="h-3.5 w-3.5" /> Entfernen
</Button>
</div>
</fieldset>
);
})}
</div>
)}
<div>
<Button variant="secondary" size="sm" onClick={hinzufuegen}>
<Plus className="h-4 w-4" /> Angehörige:n hinzufügen
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { SelectField, TextField } from "@/components/ui/Field";
import { EMERGENCY_RELATIONS } from "@/lib/supabase/types";
import type { HireDraftData } from "./types";
// Eigener Schritt, kurz vor der Zusammenfassung.
//
// Zuerst stand das zwischen den Stammdaten — dort ging es unter, obwohl es
// die einzige Angabe im ganzen Assistenten ist, die eine dritte Person
// betrifft und im Ernstfall gebraucht wird.
//
// Die Angabe bleibt freiwillig. Wer sie macht, braucht Name und Nummer
// zusammen; das prüft der Assistent, bevor er weiterlässt, und die Datenbank
// noch einmal (chk_emergency_contact).
export function StepNotfallkontakt({
draft,
update,
}: {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
}) {
const angefangen = Boolean(draft.emergencyContactName.trim() || draft.emergencyContactPhone.trim());
const unvollstaendig = angefangen && !(draft.emergencyContactName.trim() && draft.emergencyContactPhone.trim());
return (
<div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-ink-muted">
Wen sollen wir verständigen, wenn etwas passiert? Die Angabe ist freiwillig — Name und Telefonnummer gehören
aber zusammen, eines allein hilft im Ernstfall nicht.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField
label="Name"
value={draft.emergencyContactName}
onChange={(emergencyContactName) => update({ emergencyContactName })}
/>
<TextField
label="Telefon"
type="tel"
value={draft.emergencyContactPhone}
onChange={(emergencyContactPhone) => update({ emergencyContactPhone })}
/>
</div>
<SelectField
label="Verhältnis"
value={draft.emergencyContactRelation}
onChange={(emergencyContactRelation) => update({ emergencyContactRelation })}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
{unvollstaendig && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
Name und Telefonnummer werden beide gebraucht — oder beide leer lassen.
</p>
)}
</div>
);
}

View File

@@ -12,6 +12,14 @@ type StepPersonProps = {
export function StepPerson({ draft, update, locations }: StepPersonProps) {
return (
<div className="flex flex-col gap-4">
<TextField
label="Personalnummer"
required
inputMode="numeric"
value={draft.personnelNumber}
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
@@ -37,8 +45,11 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
birthDate={draft.birthDate || null}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
{/* Die private Adresse, nicht die Firmenadresse — die entsteht erst
mit dem Eintritt. Freiwillig: wer keine hat oder keine angeben
will, soll nicht gezwungen sein, eine zu erfinden. */}
<TextField label="Private E-Mail" type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
</div>
<SelectField
label="Standort"

View File

@@ -1,3 +1,4 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate, fmtFullName } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
@@ -21,17 +22,25 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
const location = locations.find((l) => l.id === draft.locationId);
const flags = [
draft.isBetriebsrat && "Betriebsrat",
draft.hasDienstwagen && "Dienstwagen",
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
draft.isLateraleFuehrung && "Laterale Führung",
draft.isCLevel && "C-Level",
draft.hasKuendigungsschutz &&
`Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`,
].filter(Boolean);
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
.filter(Boolean)
.join(" ");
const rows: [string, string][] = [
["Personalnummer", draft.personnelNumber || "–"],
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", fmtDate(draft.birthDate)],
["SV-Nummer", draft.svNummer || "–"],
["E-Mail (privat)", draft.email || "–"],
["Telefon", draft.phone || "–"],
["Notfallkontakt", notfall || "–"],
["Standort", location ? `${location.name} (${location.country})` : "–"],
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],
@@ -57,9 +66,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
</div>
))}
</dl>
<p className="rounded bg-info-bg px-3 py-2 text-sm text-info-text">
Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben.
</p>
</div>
);
}

View File

@@ -1,9 +1,30 @@
import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
// though both are NOT NULL on employees — added here (defaults keep them
// effectively "free" for the user, same treatment as the karenz-start gap).
/**
* Angehörige:r, wie sie im Assistenten gesammelt wird.
*
* Eigener Typ statt der Zeile aus der Datenbank: es gibt weder eine Kennung
* noch eine Person, an der sie hängt — beides entsteht erst mit dem Anlegen.
*/
export type HireDraftAngehoerige = {
firstName: string;
lastName: string;
relationship: RelationshipType;
birthDate: string;
svNummer: string;
};
export type HireDraftData = {
/**
* Eingabe, nicht Vergabe.
*
* Muss mit Loga und Interflex übereinstimmen — als Zeichenkette geführt,
* weil ein leeres Zahlenfeld sonst als 0 im Entwurf landet.
*/
personnelNumber: string;
firstName: string;
lastName: string;
titlePrefix: string[];
@@ -27,11 +48,21 @@ export type HireDraftData = {
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
/** Nur ausgewertet, wenn hasDienstwagen gesetzt ist — so will es der CHECK. */
dienstwagenArt: DienstwagenArt;
emergencyContactName: string;
emergencyContactPhone: string;
emergencyContactRelation: string;
angehoerige: HireDraftAngehoerige[];
isLateraleFuehrung: boolean;
isCLevel: boolean;
hasKuendigungsschutz: boolean;
/** Freiwillig — leer heisst „bis auf Weiteres". */
kuendigungsschutzBis: string;
};
export const EMPTY_HIRE_DRAFT: HireDraftData = {
personnelNumber: "",
firstName: "",
lastName: "",
titlePrefix: [],
@@ -55,6 +86,13 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: false,
hasDienstwagen: false,
dienstwagenArt: "Verbrenner",
emergencyContactName: "",
emergencyContactPhone: "",
emergencyContactRelation: "",
angehoerige: [],
isLateraleFuehrung: false,
isCLevel: false,
hasKuendigungsschutz: false,
kuendigungsschutzBis: "",
};

View File

@@ -9,6 +9,7 @@ import { SearchInput } from "@/components/ui/SearchInput";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee } from "./types";
import { fmtName } from "@/lib/format";
type ViewMode = "list" | "graph";
@@ -53,7 +54,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
// Both badges name a person by id, so the lookup is shared rather than
// rebuilt per node.
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])), [employees]);
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])), [employees]);
const nameOf = useCallback((id: string | null) => (id ? nameById.get(id) : undefined), [nameById]);
const matchIds = useMemo(() => {
@@ -64,8 +65,17 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
const q = query.trim().toLowerCase();
const matches = new Set<string>();
for (const e of employees) {
// Beide Reihenfolgen: angezeigt wird „Winkler, Hannah", im Kopf hat man
// aber je nach Anlass das eine oder das andere zuerst. Wer abtippt, was
// er sieht, soll ebenso fündig werden wie jemand, der „hannah winkler"
// eingibt.
const vorNach = `${e.first_name} ${e.last_name}`.toLowerCase();
const nachVor = fmtName(e.first_name, e.last_name).toLowerCase();
if (
`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) ||
vorNach.includes(q) ||
nachVor.includes(q) ||
// Ohne den Beistrich, falls jemand ihn beim Abtippen weglässt.
nachVor.replace(",", "").includes(q) ||
e.job_title.toLowerCase().includes(q) ||
String(e.personnel_number).includes(q)
) {
@@ -118,7 +128,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
return {
id: e.id,
kind: "person",
label: `${e.first_name} ${e.last_name}`,
label: fmtName(e.first_name, e.last_name),
sublabel: e.job_title,
href: `/employees/${e.id}`,
avatar: { firstName: e.first_name, lastName: e.last_name },
@@ -159,7 +169,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
<Link href={`/employees/${e.id}`} className="min-w-0 flex-1 hover:underline">
<span className="text-sm font-semibold text-ink">
{e.first_name} {e.last_name}
{fmtName(e.first_name, e.last_name)}
</span>
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
</Link>

View File

@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { Printer } from "lucide-react";
import Link from "next/link";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { AsOfPicker } from "./AsOfPicker";
import { EmployeeTree } from "./EmployeeTree";
@@ -35,14 +37,25 @@ export function OrgChartClient({
return (
<div className="flex flex-col gap-4">
<SegmentedControl<View>
value={view}
onChange={setView}
options={[
{ value: "ma", label: "Mitarbeiter" },
{ value: "pos", label: "Organisation" },
]}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<SegmentedControl<View>
value={view}
onChange={setView}
options={[
{ value: "ma", label: "Mitarbeiter" },
{ value: "pos", label: "Organisation" },
]}
/>
{/* Der Stichtag wandert mit: wer eine vergangene Struktur ansieht,
druckt sie auch. */}
<Link
href={asOf === today ? "/orgchart/print" : `/orgchart/print?asOf=${asOf}`}
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<Printer className="h-4 w-4" />
Als PDF
</Link>
</div>
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />

View File

@@ -6,6 +6,7 @@ import { useCallback, useMemo, useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
import { fmtName } from "@/lib/format";
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
@@ -178,7 +179,7 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va
.map((p) => ({
id: p.id,
kind: "person" as const,
label: `${p.first_name} ${p.last_name}`,
label: fmtName(p.first_name, p.last_name),
href: `/employees/${p.id}`,
avatar: { firstName: p.first_name, lastName: p.last_name },
absent: p.absent,
@@ -204,7 +205,7 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va
kind: "role",
label: `${unit.org_number} · ${unit.name}`,
sublabel: chief
? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}`
? `Leitung: ${fmtName(chief.first_name, chief.last_name)}${chief.absent ? " (abwesend)" : ""}`
: "Leitung vakant",
vacant: !chief,
children: [...subUnits, ...titleGroups, ...vacancyNodes],

View File

@@ -0,0 +1,609 @@
"use client";
import { ChevronRight, Printer } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { fmtDate } from "@/lib/format";
import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print";
// Auswahl, dann Vorschau, dann Papier.
//
// Drei Entscheidungen prägen das hier:
//
// **Erstens wird gefragt, bevor gedruckt wird.** Ein Organigramm mit 810
// Personen auf Papier ist fast nie das, was jemand braucht — meist geht es um
// einen Bereich, oder um die oberen zwei Ebenen für eine Besprechung. Wer
// alles bekommt, druckt vierzig Blätter und wirft achtunddreissig weg.
//
// **Zweitens wird hierarchisch gezeichnet, nicht aufgelistet.** Kästen mit
// Verbindungslinien zeigen die Über- und Unterordnung; eine Spaltenliste
// zeigt sie nicht, egal wie sauber sie gesetzt ist. Die Linien entstehen aus
// Rahmen von Pseudo-Elementen — damit sind sie im PDF Vektoren und bleiben
// beim Hineinzoomen scharf.
//
// **Drittens breitet sich nur eine Ebene waagrecht aus.** Ein Baum, der auf
// jeder Stufe in die Breite geht, wächst exponentiell: sechs Bereiche mit je
// drei Abteilungen sind achtzehn Kästen nebeneinander, und quer über ein A4
// bleiben davon zwei Millimeter je Kasten — die erste Fassung lief genau
// deshalb über den Blattrand hinaus. Jetzt stehen die Bereiche in einer
// Reihe, und alles darunter hängt längs an einer Linie: die Breite ist die
// Zahl der Bereiche, unabhängig von der Tiefe. Was dann immer noch übersteht,
// wird als Ganzes so weit verkleinert, bis es aufs Blatt passt.
type Tiefe = "bereich" | "abteilung" | "team" | "personen";
const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [
{ wert: "bereich", label: "Bereiche", hinweis: "Gesellschaft und Bereiche — ein Blatt" },
{ wert: "abteilung", label: "bis Abteilung", hinweis: "eine Ebene tiefer, noch immer ein Blatt" },
{ wert: "team", label: "bis Team", hinweis: "je Bereich ein Blatt, ohne Namen" },
{ wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" },
];
type Format = "a4" | "a3";
// Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein
// wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest
// zugewiesen — dadurch ist das, was am Bildschirm zu sehen ist, dasselbe, was
// aufs Papier passt, und nicht bloss ungefähr.
const FORMATE: Record<Format, { label: string; css: string; blatt: string; satzBreite: string; satzHoehe: string }> = {
a4: { label: "A4 quer", css: "A4 landscape", blatt: "297mm", satzBreite: "268mm", satzHoehe: "182mm" },
a3: { label: "A3 quer", css: "A3 landscape", blatt: "420mm", satzBreite: "390mm", satzHoehe: "269mm" },
};
/** Unter diesem Massstab wird es auf A4 mühsam — dann lieber A3. */
const LESBAR = 0.55;
type Seite = {
key: string;
titel: string;
unterzeile: string;
wurzel: PrintUnit;
/** Nur auf der Übersicht: die gewählten Bereiche statt aller Kinder. */
kinder?: PrintUnit[];
/** Wie viele Ebenen unter der Wurzel gezeichnet werden. */
ebenen: number;
mitPersonen: boolean;
};
function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asOf: string): Seite[] {
const stand = `Stand ${fmtDate(asOf)}`;
const seiten: Seite[] = [];
if (model.root) {
seiten.push({
key: "uebersicht",
titel: model.root.name,
unterzeile: `Organigramm · ${stand} · ${model.totals.people} Personen`,
wurzel: model.root,
kinder: bereiche,
// Bei „bis Abteilung" ist die Übersicht das ganze Ergebnis. Sonst folgen
// Detailblätter, und dann bleibt sie eine reine Bereichsübersicht.
ebenen: tiefe === "abteilung" ? 2 : 1,
mitPersonen: false,
});
}
if (tiefe === "team") {
for (const b of bereiche) {
seiten.push({
key: b.id,
titel: b.name,
unterzeile: `${b.totalPeople} Personen · ${stand}`,
wurzel: b,
ebenen: 2,
mitPersonen: false,
});
}
}
if (tiefe === "personen") {
for (const b of bereiche) {
// Wer unmittelbar im Bereich sitzt, hätte sonst kein Blatt — die
// Leitung steht auf der Übersicht, die übrigen nirgends.
if (b.members.length > 0) {
seiten.push({
key: `${b.id}-direkt`,
titel: b.name,
unterzeile: `Bereichsleitung und Stab · ${stand}`,
wurzel: b,
ebenen: 0,
mitPersonen: true,
});
}
for (const a of b.children) {
seiten.push({
key: a.id,
titel: `${b.name} · ${a.name}`,
unterzeile: `${a.totalPeople} Personen · ${stand}`,
wurzel: a,
ebenen: 1,
mitPersonen: true,
});
}
}
}
return seiten;
}
export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) {
const [format, setFormat] = useState<Format>("a4");
const [tiefe, setTiefe] = useState<Tiefe>("team");
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => new Set(model.divisions.map((d) => d.id)));
const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl");
const bereiche = useMemo(() => model.divisions.filter((d) => gewaehlt.has(d.id)), [model.divisions, gewaehlt]);
const seiten = useMemo(() => seitenBauen(model, bereiche, tiefe, asOf), [model, bereiche, tiefe, asOf]);
// Der kleinste Massstab aller Blätter — daran hängt der Hinweis auf A3.
//
// Die gemeldeten Werte tragen die Auswahl mit, für die sie gemessen wurden:
// ändert sie sich, sind die alten Werte nicht mehr gültig und werden
// verworfen, sobald das erste Blatt der neuen Auswahl meldet. Das erspart
// einen Effekt, der beim Wechsel aufräumt — und damit eine Renderrunde,
// in der noch der Hinweis der vorherigen Auswahl stünde.
const signatur = `${format}|${tiefe}|${[...gewaehlt].sort().join(",")}`;
const [gemessen, setGemessen] = useState<{ signatur: string; werte: Record<string, number> }>({ signatur, werte: {} });
const meldeMassstab = useCallback(
(key: string, faktor: number) => {
setGemessen((prev) => {
// Unverändert heisst unverändert: denselben Zustand zurückgeben, nicht
// ein gleich aussehendes neues Objekt. Ein neues Objekt zählt für React
// als Änderung und löst ein Rendern aus — und da das Rendern die
// Messung anstösst, die wieder hier landet, dreht sich das ohne Ende.
// Genau daran ist die Vorschau eingefroren.
if (prev.signatur === signatur && prev.werte[key] === faktor) return prev;
return prev.signatur === signatur
? { signatur, werte: { ...prev.werte, [key]: faktor } }
: { signatur, werte: { [key]: faktor } };
});
},
[signatur]
);
const engste = Math.min(1, ...Object.values(gemessen.signatur === signatur ? gemessen.werte : {}));
function umschalten(id: string) {
setGewaehlt((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="flex flex-col gap-6">
<style>{`
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
@media print {
html, body { background: #fff; }
.nur-bildschirm { display: none !important; }
.blatt {
box-shadow: none !important; border: 0 !important; border-radius: 0 !important;
margin: 0 !important; width: auto !important; padding: 0 !important;
break-after: page;
}
.blatt:last-child { break-after: auto; }
/* Die Satzfläche behält ihre Masse — der Massstab wurde für genau
diese berechnet. Nur der Rand fällt weg, den setzt @page. */
.satz { margin: 0 !important; }
}
/* Eine Reihe: waagrechte Geschwister mit Winkeln nach oben. Das erste
und das letzte Kind lassen ihre Aussenseite weg — zusammen ergibt
das eine durchgehende Querlinie ohne ein einziges Hilfselement. */
ul.reihe {
position: relative; display: flex; justify-content: center;
list-style: none; margin: 0; padding: 18px 0 0;
}
ul.reihe::before {
content: ""; position: absolute; top: 0; left: 50%;
height: 18px; border-left: 1px solid var(--color-border);
}
ul.reihe > li { position: relative; width: var(--spalte); padding: 18px 6px 0; }
ul.reihe > li::before, ul.reihe > li::after {
content: ""; position: absolute; top: 0; width: 50%; height: 18px;
border-top: 1px solid var(--color-border);
}
ul.reihe > li::before { right: 50%; }
ul.reihe > li::after { left: 50%; border-left: 1px solid var(--color-border); }
ul.reihe > li:first-child::before { border-top: 0; }
ul.reihe > li:last-child::after { border-top: 0; }
/* Ein Stapel: längs an einer Linie, mit Winkel in jeden Kasten. Kostet
Höhe statt Breite — und Höhe ist auf einem Querformat übrig. */
ul.stapel { list-style: none; margin: 0; padding: 0; text-align: left; }
ul.stapel > li { position: relative; padding: 8px 0 0 20px; }
ul.stapel > li::before {
content: ""; position: absolute; left: 9px; top: 0; bottom: 0;
border-left: 1px solid var(--color-border);
}
ul.stapel > li:last-child::before { bottom: auto; height: 22px; }
ul.stapel > li::after {
content: ""; position: absolute; left: 9px; top: 22px; width: 11px;
border-top: 1px solid var(--color-border);
}
`}</style>
{/* ── Auswahl ─────────────────────────────────────────── */}
<section className="nur-bildschirm rounded border border-border bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-ink">Was soll aufs Papier?</h2>
<p className="mt-0.5 max-w-prose text-sm text-ink-muted">
Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht,
wie tief und welche Bereiche.
</p>
</div>
<p className="whitespace-nowrap text-sm text-ink-muted">
Stand {fmtDate(asOf)}
{asOf !== today && " · abweichender Stichtag"}
</p>
</div>
<div className="mt-5">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">Tiefe</p>
<div className="flex flex-wrap gap-2">
{TIEFEN.map((t) => (
<button
key={t.wert}
type="button"
onClick={() => setTiefe(t.wert)}
aria-pressed={tiefe === t.wert}
className={`rounded-md border px-3 py-2 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
tiefe === t.wert ? "border-brand-500 bg-brand-50" : "border-border hover:bg-surface"
}`}
>
<span className="block text-sm font-semibold text-ink">{t.label}</span>
<span className="block text-xs text-ink-muted">{t.hinweis}</span>
</button>
))}
</div>
</div>
<div className="mt-5">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wide text-ink-muted">
Bereiche ({gewaehlt.size} von {model.divisions.length})
</p>
<div className="flex gap-3 text-xs">
<button
type="button"
onClick={() => setGewaehlt(new Set(model.divisions.map((d) => d.id)))}
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Alle
</button>
<button
type="button"
onClick={() => setGewaehlt(new Set())}
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Keine
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{model.divisions.map((d) => (
<label
key={d.id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 ${
gewaehlt.has(d.id) ? "border-brand-500 bg-brand-50" : "border-border"
}`}
>
<input
type="checkbox"
checked={gewaehlt.has(d.id)}
onChange={() => umschalten(d.id)}
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600"
/>
<span>
<span className="block text-sm font-semibold text-ink">{d.name}</span>
<span className="block text-xs text-ink-muted">
{d.totalPeople} Personen · {d.children.length} Abteilungen
</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle pt-4">
<p className="text-sm text-ink-muted">
{gewaehlt.size === 0 ? (
<span className="text-danger-text">Kein Bereich gewählt — es bliebe nur die Übersicht.</span>
) : (
<>
Ergibt <strong className="text-ink">{seiten.length}</strong> {seiten.length === 1 ? "Seite" : "Seiten"}
</>
)}
</p>
<div className="flex items-center gap-3">
<Link
href="/orgchart"
className="rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Zurück
</Link>
<Button onClick={() => setSchritt("vorschau")}>
Vorschau <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{schritt === "vorschau" && (
<>
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-ink-muted">
{seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker
</p>
<div className="flex items-center gap-3">
<div className="flex rounded-md border border-border p-0.5">
{(Object.keys(FORMATE) as Format[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
aria-pressed={format === f}
className={`rounded px-3 py-1.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
format === f ? "bg-brand-500 text-white" : "text-ink-muted hover:bg-surface"
}`}
>
{FORMATE[f].label}
</button>
))}
</div>
<Button onClick={() => window.print()}>
<Printer className="h-4 w-4" />
Als PDF speichern
</Button>
</div>
</div>
{format === "a4" && engste < LESBAR && (
<p className="nur-bildschirm rounded border border-warning-text/30 bg-warning-bg px-3 py-2 text-sm text-warning-text">
Ein Blatt musste auf {Math.round(engste * 100)} % verkleinert werden, damit es passt — auf A4 kaum noch
lesbar. A3 wählen oder weniger Bereiche.
</p>
)}
<p className="nur-bildschirm max-w-prose text-xs leading-relaxed text-ink-muted">
Im Druckdialog &bdquo;Als PDF speichern&ldquo; wählen. Hintergrundgrafiken müssen dort eingeschaltet sein,
sonst fehlen die farbigen Kopfzeilen &mdash; in Chrome unter &bdquo;Weitere Einstellungen&ldquo;.
</p>
<div className="flex flex-col items-center gap-8">
{seiten.map((seite, i) => (
<Blatt key={seite.key} format={format}>
<Kopf
titel={seite.titel}
unterzeile={seite.unterzeile}
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
/>
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
<Chart seite={seite} />
</Skaliert>
</Blatt>
))}
</div>
</>
)}
</div>
);
}
function Blatt({ children, format }: { children: React.ReactNode; format: Format }) {
const { blatt, satzBreite, satzHoehe } = FORMATE[format];
return (
<div className="blatt rounded border border-border bg-white shadow-[var(--shadow-card)]" style={{ width: blatt }}>
<div className="satz flex flex-col" style={{ width: satzBreite, height: satzHoehe, margin: "12mm 14mm 14mm" }}>
{children}
</div>
</div>
);
}
function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) {
return (
<header className="mb-5 flex shrink-0 items-end justify-between border-b-2 border-brand-500 pb-2">
<div>
<h1 className="text-lg font-extrabold tracking-tight text-ink">{titel}</h1>
<p className="mt-0.5 text-xs text-ink-muted">{unterzeile}</p>
</div>
<p className="whitespace-nowrap text-xs font-semibold text-ink-muted">{seite}</p>
</header>
);
}
/**
* Verkleinert den Inhalt so weit, dass er in die Satzfläche passt.
*
* Ein Organigramm hat keine Grösse, die man vorher kennt — sie hängt daran,
* wie viele Bereiche jemand angehakt hat und wie tief. Statt zu raten wird
* gemessen: die natürliche Grösse steht im Layout, die verfügbare auch, und
* `transform: scale` ändert nur die Darstellung, nicht das Layout. Damit ist
* die Messung stabil und läuft nicht gegen sich selbst.
*/
function Skaliert({
kennung,
onFaktor,
children,
}: {
kennung: string;
onFaktor: (faktor: number) => void;
children: React.ReactNode;
}) {
const rahmen = useRef<HTMLDivElement>(null);
const inhalt = useRef<HTMLDivElement>(null);
const [faktor, setFaktor] = useState(1);
const [versatz, setVersatz] = useState(0);
// Die Meldefunktion wird beim Aufruf frisch erzeugt und ist daher bei jedem
// Rendern eine andere. Stünde sie in den Abhängigkeiten, liefe der Effekt
// nach jedem Rendern erneut — samt Messung, die das nächste Rendern
// auslöst. Über eine Referenz bleibt der aktuelle Stand erreichbar, ohne
// dass der Effekt daran hängt.
const melden = useRef(onFaktor);
useEffect(() => {
melden.current = onFaktor;
});
useLayoutEffect(() => {
const aussen = rahmen.current;
const innen = inhalt.current;
if (!aussen || !innen) return;
function messen() {
if (!aussen || !innen) return;
const breite = aussen.clientWidth;
const hoehe = aussen.clientHeight;
const noetigeBreite = innen.offsetWidth;
const noetigeHoehe = innen.offsetHeight;
if (!breite || !hoehe || !noetigeBreite || !noetigeHoehe) return;
const f = Math.min(1, breite / noetigeBreite, hoehe / noetigeHoehe);
setFaktor(f);
// Waagrecht mittig: die Verkleinerung geht von links oben aus, sonst
// klebt ein schmales Diagramm am linken Blattrand.
setVersatz(Math.max(0, (breite - noetigeBreite * f) / 2));
melden.current(f);
}
messen();
// Schriften kommen nach dem ersten Layout — bis dahin sind alle Kästen in
// der Ersatzschrift gesetzt und damit anders breit.
void document.fonts?.ready.then(messen);
const beobachter = new ResizeObserver(messen);
beobachter.observe(aussen);
return () => beobachter.disconnect();
}, [kennung]);
return (
<div ref={rahmen} className="min-h-0 flex-1 overflow-hidden">
<div
ref={inhalt}
style={{
width: "max-content",
transform: `scale(${faktor})`,
transformOrigin: "top left",
marginLeft: versatz,
}}
>
{children}
</div>
</div>
);
}
function Chart({ seite }: { seite: Seite }) {
const kinder = seite.kinder ?? seite.wurzel.children;
const zeigeKinder = seite.ebenen > 0 && kinder.length > 0;
return (
// Feste Spaltenbreite: dadurch sitzt jeder Kasten mittig unter seiner
// Verbindungslinie, und die Gesamtbreite ist die Zahl der Spalten mal
// dieser Breite — berechenbar, statt vom längsten Namen abzuhängen.
<div className="inline-block text-center" style={{ ["--spalte" as string]: "13rem" }}>
<div className="mx-auto w-[15rem]">
<Kasten unit={seite.wurzel} mitPersonen={seite.mitPersonen} />
</div>
{zeigeKinder && (
<ul className="reihe">
{kinder.map((k) => (
<li key={k.id}>
<Kasten unit={k} mitPersonen={seite.mitPersonen} />
<Stapel units={k.children} ebenen={seite.ebenen - 1} mitPersonen={seite.mitPersonen} />
</li>
))}
</ul>
)}
</div>
);
}
function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: number; mitPersonen: boolean }) {
if (ebenen <= 0 || units.length === 0) return null;
return (
<ul className="stapel">
{units.map((u) => (
<li key={u.id}>
<Kasten unit={u} mitPersonen={mitPersonen} />
<Stapel units={u.children} ebenen={ebenen - 1} mitPersonen={mitPersonen} />
</li>
))}
</ul>
);
}
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
// Nach unten hin blasser: so bleibt die Rangfolge auch auf dem
// Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen.
Gesellschaft: "bg-brand-500 text-white",
Bereich: "bg-brand-100 text-ink",
Abteilung: "bg-brand-50 text-ink",
Team: "bg-surface text-ink",
};
function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) {
const offen = unit.vacancies.filter((v) => !v.isChief);
// Lange Namenslisten in zwei Spalten: sonst wächst der Kasten in die Höhe,
// und die Höhe ist es, die den Massstab drückt.
const zweispaltig = mitPersonen && unit.members.length > 8;
return (
<div className="w-full overflow-hidden rounded border border-border text-left">
<div className={`px-2 py-1 ${KOPFFARBE[unit.unitType]}`}>
<p className="text-[11px] font-bold leading-tight">{unit.name}</p>
<p
className={`text-[9px] uppercase tracking-wide ${
unit.unitType === "Gesellschaft" ? "text-white/75" : "text-ink-muted"
}`}
>
{unit.unitType} · {unit.totalPeople}
</p>
</div>
<div className="px-2 py-1">
{unit.chief ? (
<PersonZeile p={unit.chief} leitung />
) : (
<p className="text-[10px] italic text-ink-muted">Leitung unbesetzt</p>
)}
{mitPersonen && unit.members.length > 0 && (
<div className={`mt-1 border-t border-border-subtle pt-1 ${zweispaltig ? "columns-2 gap-2" : ""}`}>
{unit.members.map((p) => (
<div key={p.id} className="break-inside-avoid">
<PersonZeile p={p} />
</div>
))}
</div>
)}
{mitPersonen &&
offen.map((v) => (
<p key={v.positionNumber} className="text-[10px] italic text-ink-muted">
offen · {v.jobTitle}
</p>
))}
{!mitPersonen && unit.members.length > 0 && (
<p className="text-[10px] text-ink-muted">
{unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"}
</p>
)}
</div>
</div>
);
}
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
return (
<p className="text-[10px] leading-tight">
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
<br />
<span className="text-ink-muted">{p.jobTitle}</span>
</p>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { setPositionCostCenter, updatePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { UnitOption } from "./CreatePositionModal";
// Ändern statt löschen und neu anlegen.
//
// Die Planstellennummer steht in Ausschreibungen, Budgets und
// Protokolleinträgen. Wer wegen eines Tippfehlers in der Tätigkeit eine neue
// Nummer vergibt, macht die alten Bezüge wertlos — deshalb gibt es diesen
// Dialog.
/**
* Erwartet eine Planstelle, keine „vielleicht keine".
*
* Die aufrufende Seite hängt einen `key` mit der Kennung daran und rendert
* ihn nur, solange etwas bearbeitet wird. Dadurch baut React den Dialog je
* Planstelle neu auf, und die Felder lassen sich direkt aus den Eigenschaften
* vorbelegen — statt sie in einem Effekt nachzuziehen, der beim zweiten
* Öffnen kurz die Werte der vorigen Stelle zeigt.
*/
export function EditPositionModal({
position,
units,
kostenstellen,
heute,
onClose,
}: {
position: OpenPositionResolved;
units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
heute: string;
onClose: () => void;
}) {
const { showToast } = useToast();
const router = useRouter();
const [jobTitle, setJobTitle] = useState(position.title);
const [orgUnitId, setOrgUnitId] = useState(position.org_unit_id);
const [isChief, setIsChief] = useState(position.is_chief);
const [validFrom, setValidFrom] = useState(position.valid_from);
const [validTo, setValidTo] = useState(position.valid_to ?? "");
// Die Kontierung ist ein eigener Vorgang mit eigenem Stichtag, kein Feld
// unter anderen: die laufende wird beendet, die neue beginnt. Deshalb steht
// hier ein Datum daneben und kein stilles Überschreiben.
const [kostenstelleId, setKostenstelleId] = useState(
kostenstellen.find((k) => k.code === position.kostenstelle?.code)?.id ?? ""
);
const [kostenstelleAb, setKostenstelleAb] = useState(heute);
const [pending, setPending] = useState(false);
const kostenstelleGeaendert =
kostenstelleId !== "" && kostenstellen.find((k) => k.id === kostenstelleId)?.code !== position.kostenstelle?.code;
const unit = units.find((u) => u.id === orgUnitId);
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
async function handleSubmit() {
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
return;
}
setPending(true);
const result = await updatePosition({
position_id: position.id,
org_unit_id: orgUnitId,
job_title: jobTitle.trim(),
is_chief: isChief && !chiefTaken,
valid_from: validFrom,
valid_to: validTo || null,
});
if (result.success && kostenstelleGeaendert) {
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
// fehl, steht die übrige Änderung trotzdem — und die Meldung sagt, was
// offen blieb, statt beides stillschweigend zu verwerfen.
const kosten = await setPositionCostCenter({
position_id: position.id,
cost_center_id: kostenstelleId,
valid_from: kostenstelleAb,
});
if (!kosten.success) {
setPending(false);
showToast(kosten.error ?? "Die Kostenstelle konnte nicht geändert werden.", "error");
router.refresh();
return;
}
}
setPending(false);
if (result.success) {
showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert.");
router.refresh();
onClose();
} else {
// Die Meldungen der Datenbankfunktion sind für die Oberfläche
// geschrieben („Diese Planstelle ist vergeben …“) und werden gezeigt.
showToast(result.error ?? "Die Änderung war nicht möglich.", "error");
}
}
return (
<Modal
open
onClose={onClose}
title={`Planstelle ${position.position_number}`}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Speichern
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<TextField label="Tätigkeit" required value={jobTitle} onChange={setJobTitle} />
<SelectField
label="Organisationseinheit"
required
value={orgUnitId}
onChange={setOrgUnitId}
placeholder="Bitte wählen…"
options={units.map((u) => ({
value: u.id,
label: `${"  ".repeat(u.depth)}${u.name} (${u.unit_type})`,
}))}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
</div>
<SelectField
label="Kostenstelle"
value={kostenstelleId}
onChange={setKostenstelleId}
placeholder="Keine"
options={kostenstellen.map((k) => ({ value: k.id, label: `${k.code} · ${k.name}` }))}
/>
{kostenstelleGeaendert && (
<div className="rounded border border-border bg-surface px-3 py-2">
<TextField
label="Umkontieren ab"
required
type="date"
value={kostenstelleAb}
onChange={setKostenstelleAb}
min={position.valid_from}
/>
<p className="mt-1.5 text-xs text-ink-muted">
Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es
gebucht wurde.
</p>
</div>
)}
<label className="flex items-start gap-2 text-sm text-ink-body">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
checked={isChief && !chiefTaken}
disabled={chiefTaken}
onChange={(e) => setIsChief(e.target.checked)}
/>
<span>
Leitungsplanstelle
{chiefTaken && (
<span className="block text-xs text-ink-muted">
Für {unit?.name} besteht bereits eine Leitungsplanstelle.
</span>
)}
</span>
</label>
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
Die Planstellennummer bleibt. Ist die Stelle vergeben, lassen sich Einheit und Gültigkeitsende nicht ändern —
ein Wechsel der Einheit ist eine Versetzung und gehört zur Person, nicht zur Stelle.
</p>
</div>
</Modal>
);
}

View File

@@ -9,20 +9,28 @@ import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
import { EditPositionModal } from "./EditPositionModal";
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
type PositionsPageClientProps = {
openPositions: OpenPositionWithDays[];
units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
};
export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) {
export function PositionsPageClient({ openPositions, units, kostenstellen }: PositionsPageClientProps) {
const { showToast } = useToast();
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const today = todayIso();
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
// und „das entsteht erst". In einer Liste vermischt liest sich eine
// Planstelle zum 01.10. wie eine Vakanz, um die sich niemand kümmert.
const offen = openPositions.filter((p) => !p.future);
const kuenftig = openPositions.filter((p) => p.future);
async function handleDelete(id: string) {
setDeletingId(id);
@@ -36,53 +44,101 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
}
}
function Karte({ p }: { p: OpenPositionWithDays }) {
return (
<div className="relative flex flex-col rounded border border-border transition-colors focus-within:border-brand-500 hover:border-brand-500">
{/* Die ganze Karte öffnet den Änderungsdialog; der Papierkorb liegt
absolut darüber. Ein Knopf im Knopf wäre ungültiges HTML und mit
der Tastatur nicht erreichbar. */}
<button
type="button"
onClick={() => setEditing(p)}
aria-label={`Planstelle ${p.position_number} (${p.title}) ändern`}
className="flex flex-col items-start rounded p-3 pr-10 text-left
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="text-sm font-semibold text-ink">{p.title}</span>
<span className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</span>
<span className="mt-1 text-xs text-ink-muted">
{p.is_chief ? "Leitungsplanstelle · " : ""}
{p.future ? `gültig ab ${fmtDate(p.valid_from)}` : `seit ${p.daysOpen} Tagen unbesetzt`}
</span>
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
{/* Was die Stelle kostet und wen es trifft — die Frage, die bei einer
Vakanz zuerst kommt. */}
{p.kostenstelle && (
<span className="text-xs text-ink-muted">
Kostenstelle <span className="font-semibold tabular-nums text-ink-body">{p.kostenstelle.code}</span> ·{" "}
{p.kostenstelle.name}
</span>
)}
</button>
<Button
variant="icon"
onClick={() => handleDelete(p.id)}
pending={deletingId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
className="absolute right-2 top-2 hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="rounded border border-border bg-white p-4">
<section className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({openPositions.length})</h2>
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Planstelle anlegen
</Button>
</div>
{openPositions.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit ist jede Planstelle besetzt.</p>
{offen.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit ist jede geltende Planstelle besetzt oder vergeben.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{openPositions.map((p) => {
const notYetValid = p.valid_from > today;
return (
<div key={p.id} className="flex flex-col rounded border border-border p-3">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-semibold text-ink">{p.title}</div>
<Button
variant="icon"
onClick={() => handleDelete(p.id)}
pending={deletingId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
className="-mr-1 -mt-1 hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</div>
<div className="mt-1 text-xs text-ink-muted">
{p.is_chief ? "Leitungsplanstelle · " : ""}
seit {p.daysOpen} Tagen unbesetzt
</div>
{p.managerName && <div className="text-xs text-ink-muted">berichtet an {p.managerName}</div>}
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
</div>
);
})}
{offen.map((p) => (
<Karte key={p.id} p={p} />
))}
</div>
)}
</div>
</section>
{kuenftig.length > 0 && (
<section className="rounded border border-border bg-white p-4">
<h2 className="text-sm font-bold text-ink">Künftige Planstellen ({kuenftig.length})</h2>
<p className="mb-3 mt-1 text-xs text-ink-muted">
Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{kuenftig.map((p) => (
<Karte key={p.id} p={p} />
))}
</div>
</section>
)}
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
{/* `key` sorgt dafür, dass React je Planstelle einen frischen Dialog
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
stehen. */}
{editing && (
<EditPositionModal
key={editing.id}
position={editing}
units={units}
kostenstellen={kostenstellen}
heute={todayIso()}
onClose={() => setEditing(null)}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
import { ChevronDown, FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
@@ -10,6 +10,14 @@ import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import {
anzahlKriterien,
AUSWAHL_KRITERIEN,
JANEIN_KRITERIEN,
kriterienParameter,
ZEITRAUM_KRITERIEN,
type Criteria,
} from "@/lib/report-criteria";
import {
AVERAGE_MEASURES,
EVENT_DATE_OPEN,
@@ -27,6 +35,7 @@ import {
type GroupDimension,
type Measure,
type ReportRow,
type UnitOption,
} from "@/lib/reports";
import type { HistoryEventType } from "@/lib/supabase/types";
@@ -40,7 +49,7 @@ type CommonProps = {
rows: ReportRow[];
total: number;
recordCount: number;
divisions: OrgOption[];
units: UnitOption[];
locations: OrgOption[];
savedReports: SavedReport[];
};
@@ -51,7 +60,8 @@ type SnapshotProps = CommonProps & {
group: GroupDimension;
split: GroupDimension | "";
asOf: string;
filters: { division: string; location: string; status: string; employment: string };
filters: { division: string; location: string; status: string };
criteria: Criteria;
};
type EventsProps = CommonProps & {
@@ -64,6 +74,144 @@ type EventsProps = CommonProps & {
type ReportsPageClientProps = SnapshotProps | EventsProps;
/**
* Alle übrigen Auswahlkriterien, eingeklappt.
*
* Aufgeklappt wäre die Leiste dreimal so lang wie der Bericht daneben, und
* die vier Filter, die fast immer gemeint sind, würden darin untergehen.
* Eingeklappt steht nur die Zahl der gesetzten Kriterien da — und die ist
* das, was man wissen muss: ob gerade etwas einschränkt, das man vergessen
* hat.
*/
function KriterienBlock({
criteria,
onChange,
onReset,
}: {
criteria: Criteria;
onChange: (patch: Record<string, string | undefined>) => void;
onReset: () => void;
}) {
const [offen, setOffen] = useState(false);
const anzahl = anzahlKriterien(criteria);
return (
<div className="mt-2 border-t border-border-subtle pt-2">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setOffen(!offen)}
aria-expanded={offen}
className="flex items-center gap-1 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${offen ? "rotate-180" : ""}`} />
Weitere Kriterien
{anzahl > 0 && <span className="rounded-full bg-brand-500 px-1.5 text-[10px] text-white">{anzahl}</span>}
</button>
{anzahl > 0 && (
<button
type="button"
onClick={onReset}
className="rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Zurücksetzen
</button>
)}
</div>
{offen && (
<div className="mt-3 flex flex-col gap-2">
{AUSWAHL_KRITERIEN.map((k) => (
<select
key={k.key}
aria-label={`Nach ${k.label} filtern`}
value={criteria.auswahl[k.key] ?? ""}
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
className={CONTROL_CLASS}
>
<option value="">{k.alle}</option>
{k.optionen.map((o) => (
<option key={o.wert} value={o.wert}>
{o.label}
</option>
))}
</select>
))}
<fieldset className="rounded border border-border px-3 py-2">
<legend className="mb-1.5 text-xs font-semibold text-ink-muted">Merkmale</legend>
<div className="flex flex-col gap-1.5">
{JANEIN_KRITERIEN.map((k) => (
<div key={k.key} className="flex items-center justify-between gap-2 text-sm text-ink-body">
<span>{k.label}</span>
<select
aria-label={`${k.label}: ja oder nein`}
value={criteria.jaNein[k.key] ?? ""}
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
className="rounded border border-border px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<option value="">egal</option>
<option value="ja">ja</option>
<option value="nein">nein</option>
</select>
</div>
))}
</div>
</fieldset>
{ZEITRAUM_KRITERIEN.map((k) => (
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
label="Von"
dense
type="date"
value={criteria.zeitraum[k.key]?.von ?? ""}
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
/>
<TextField
label="Bis"
dense
type="date"
value={criteria.zeitraum[k.key]?.bis ?? ""}
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
/>
</div>
</fieldset>
))}
<fieldset className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">Wochenstunden</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
label="Ab"
dense
type="number"
step="0.5"
value={criteria.stundenVon?.toString() ?? ""}
onChange={(v) => onChange({ hoursFrom: v || undefined })}
/>
<TextField
label="Bis"
dense
type="number"
step="0.5"
value={criteria.stundenBis?.toString() ?? ""}
onChange={(v) => onChange({ hoursTo: v || undefined })}
/>
</div>
</fieldset>
<p className="text-xs text-ink-muted">
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
</p>
</div>
)}
</div>
);
}
function formatValue(measure: Measure, value: number): string {
if (measure === "headcount") return String(Math.round(value));
if (measure === "fte") return value.toFixed(1);
@@ -73,7 +221,7 @@ function formatValue(measure: Measure, value: number): string {
}
export function ReportsPageClient(props: ReportsPageClientProps) {
const { mode, rows, total, recordCount, divisions, locations, savedReports } = props;
const { mode, rows, total, recordCount, units, locations, savedReports } = props;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -103,6 +251,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
updateParams({ status: next.length > 0 ? next.join(",") : undefined });
}
function resetKriterien() {
const patch: Record<string, undefined> = {};
for (const p of kriterienParameter()) patch[p] = undefined;
updateParams(patch);
}
function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) {
const sp = new URLSearchParams({ mode });
if (preset.measure) sp.set("measure", preset.measure);
@@ -126,10 +280,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
showToast("Bitte einen Namen angeben.", "error");
return;
}
const config =
mode === "snapshot"
? { mode, measure: props.measure, group: props.group, split: props.split, asOf: props.asOf, ...props.filters }
: { mode, group: props.eventGroup, split: props.eventSplit, eventType: props.eventType, ...props.eventFilters };
// Auch hier die ganze Adresszeile: ein gespeicherter Bericht, der die
// Hälfte der Kriterien vergisst, führt beim nächsten Öffnen still zu
// anderen Zahlen.
const config: Record<string, string> = Object.fromEntries(searchParams.entries());
config.mode = mode;
config.group = mode === "snapshot" ? props.group : props.eventGroup;
if (mode === "snapshot") config.measure = props.measure;
setSavingReport(true);
const result = await saveReport({ name: newReportName.trim(), config });
setSavingReport(false);
@@ -153,41 +310,30 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
}
}
// Beide Ausgänge übernehmen die Adresszeile unverändert, statt die
// Parameter einzeln aufzuzählen. Genau diese Aufzählung war der Grund,
// warum ein neues Kriterium im Bericht wirkte und im Export fehlte — man
// musste daran denken, sie an drei Stellen zu ergänzen. Jetzt sieht der
// Export das, was der Bericht gesehen hat, weil es dieselbe Auswahl ist.
function exportHref(ziel: string, format: "csv" | "xlsx"): string {
const sp = new URLSearchParams(searchParams.toString());
sp.set("format", format);
return `${ziel}?${sp.toString()}`;
}
function reportExportHref(format: "csv" | "xlsx"): string {
const sp = new URLSearchParams();
const sp = new URLSearchParams(searchParams.toString());
sp.set("format", format);
sp.set("mode", mode);
if (mode === "snapshot") {
sp.set("measure", props.measure);
sp.set("group", props.group);
if (props.split) sp.set("split", props.split);
if (props.asOf) sp.set("asOf", props.asOf);
for (const [k, v] of Object.entries(props.filters)) if (v) sp.set(k, v);
} else {
sp.set("group", props.eventGroup);
if (props.eventSplit) sp.set("split", props.eventSplit);
if (props.eventType) sp.set("eventType", props.eventType);
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
}
// Die Vorgaben stehen nicht zwingend in der Adresszeile; die Route
// braucht sie aber, um dieselbe Auswertung zu bauen.
sp.set("group", mode === "snapshot" ? props.group : props.eventGroup);
if (mode === "snapshot") sp.set("measure", props.measure);
return `/api/export/report?${sp.toString()}`;
}
function fullExportHref(format: "csv" | "xlsx"): string {
if (mode === "snapshot") {
const sp = new URLSearchParams();
sp.set("format", format);
if (props.asOf) sp.set("asOf", props.asOf);
if (props.filters.division) sp.set("division", props.filters.division);
if (props.filters.location) sp.set("location", props.filters.location);
if (props.filters.status) sp.set("status", props.filters.status);
if (props.filters.employment) sp.set("employment", props.filters.employment);
return `/api/export/employees?${sp.toString()}`;
}
const sp = new URLSearchParams();
sp.set("format", format);
if (props.eventType) sp.set("eventType", props.eventType);
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
return `/api/export/events?${sp.toString()}`;
return exportHref(mode === "snapshot" ? "/api/export/employees" : "/api/export/events", format);
}
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
@@ -367,15 +513,19 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
<div className="flex flex-col gap-2">
<select
aria-label="Nach Bereich filtern"
aria-label="Nach Organisationseinheit filtern"
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
onChange={(e) => updateParams({ division: e.target.value })}
className={CONTROL_CLASS}
>
<option value="">Alle Bereiche</option>
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
<option value="">Alle Organisationseinheiten</option>
{units.map((u) => (
// Einrückung mit geschützten Leerzeichen: ein <optgroup> je
// Ebene ginge nicht, weil Einheiten dort auswählbar bleiben
// müssen — eine Abteilung ist selbst ein Filterwert.
<option key={u.id} value={u.id}>
{"  ".repeat(u.depth)}
{u.name}
</option>
))}
</select>
@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
))}
</div>
</fieldset>
<select
aria-label="Nach Beschäftigungsart filtern"
value={props.filters.employment}
onChange={(e) => updateParams({ employment: e.target.value })}
className={CONTROL_CLASS}
>
<option value="">Alle Beschäftigungsarten</option>
<option value="Vollzeit">Vollzeit</option>
<option value="Teilzeit">Teilzeit</option>
</select>
</>
)}
</div>
{mode === "snapshot" && (
<KriterienBlock criteria={props.criteria} onChange={updateParams} onReset={resetKriterien} />
)}
</div>
<div className="rounded border border-border bg-white p-4">

View File

@@ -0,0 +1,42 @@
import type { AuditChange } from "@/lib/supabase/types";
// Was sich geändert hat, feldweise — im Protokoll und in der Historie einer
// Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu
// setzen, hiesse dass jemand sie zweimal lesen lernen muss.
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
if (text === null || text === "") {
return <span className="text-ink-muted italic">leer</span>;
}
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
}
export function AenderungsTabelle({ changes }: { changes: AuditChange[] }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="py-2 pr-4">Feld</th>
<th className="py-2 pr-4">Vorher</th>
<th className="py-2">Nachher</th>
</tr>
</thead>
<tbody>
{changes.map((c, i) => (
<tr key={`${c.feld}-${i}`} className="border-b border-border-subtle align-top last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4">
<Wert text={c.vorher} art="vorher" />
</td>
<td className="py-2">
<Wert text={c.nachher} art="nachher" />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -25,6 +25,18 @@ function focusableWithin(container: HTMLElement): HTMLElement[] {
export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject<HTMLElement | null>) {
const restoreToRef = useRef<HTMLElement | null>(null);
// onClose wird von den meisten Aufrufern bei jedem Rendern neu erzeugt.
// Stünde es in den Abhängigkeiten, liefe dieser Effekt nach jedem Rendern
// erneut — und sein Aufräumen holt den Fokus dorthin zurück, wo er vor dem
// Öffnen war. In einem Dialog mit Eingabefeldern hiesse das: nach dem
// ersten Zeichen springt der Fokus auf den Knopf, der ihn geöffnet hat, und
// der Rest der Eingabe geht ins Leere. Über eine Referenz bleibt der
// aktuelle Rückruf erreichbar, ohne dass der Effekt daran hängt.
const schliessen = useRef(onClose);
useEffect(() => {
schliessen.current = onClose;
});
useEffect(() => {
if (!open) return;
const container = containerRef.current;
@@ -39,7 +51,7 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
schliessen.current();
return;
}
if (e.key !== "Tab" || !container) return;
@@ -74,5 +86,5 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
// there instead of restarting at the top of the page.
restoreToRef.current?.focus();
};
}, [open, onClose, containerRef]);
}, [open, containerRef]);
}

45
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,45 @@
# Reverse Proxy für den Betrieb im Firmennetz.
#
# Der Name ist öffentlich (hr.elycon.solutions), die Adresse dahinter privat
# (10.x). Das ist erlaubt und der übliche Weg: der DNS-Eintrag ist von aussen
# auflösbar, der Server nicht erreichbar.
#
# Warum das der Aufwand wert ist: Entra ID akzeptiert `http` nur für
# localhost. Ohne HTTPS gibt es keine Anmeldung — und ein selbst signiertes
# Zertifikat müsste auf jedem Arbeitsplatz als vertrauenswürdig hinterlegt
# werden. Mit der DNS-Challenge kommt ein regulär vertrauenswürdiges
# Zertifikat zustande, ohne dass der Server je aus dem Internet erreichbar
# sein muss.
#
# Voraussetzung: ein Caddy-Build mit dem DNS-Modul des eigenen Anbieters,
# etwa
# xcaddy build --with github.com/caddy-dns/cloudflare
# Andere Anbieter siehe https://github.com/caddy-dns
#
# Der API-Schlüssel gehört in eine Umgebungsvariable des Dienstes, nicht in
# diese Datei.
hr.elycon.solutions {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# Die Anwendung lauscht nur auf der Loopback-Adresse (siehe
# docker-compose.yml), erreichbar ist sie also ausschliesslich über
# diesen Proxy.
reverse_proxy 127.0.0.1:3000
# Ohne diese Weitergabe baut Auth.js seine Rückruf-Adresse aus dem
# Container-Hostnamen statt aus dem echten Namen — die Anmeldung landet
# dann auf einer Adresse, die niemand kennt. `trustHost` in
# lib/auth/config.ts wertet genau diese Header aus.
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
encode gzip zstd
log {
output file /var/log/caddy/hr.log
format json
}
}

View File

@@ -6,8 +6,12 @@ services:
build:
context: .
restart: unless-stopped
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
# beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen,
# und ein Fehler in der Firewall genügte.
ports:
- "3000:3000"
- "127.0.0.1:3000:3000"
env_file:
- .env

View File

@@ -9,7 +9,7 @@ Supabase als Abhängigkeit entfernt.
| | Anzahl |
|---|---|
| RLS-Policies | 58 |
| RLS-Policies | 21 |
| `auth.uid()` / `auth.users` in Migrationen | 79 |
| Fremdschlüssel auf `auth.users` | 9 |
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
@@ -35,7 +35,7 @@ language sql security definer stable as $$
$$;
```
Alle 58 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
Alle 21 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
war meine Sorge bei Variante B, und sie ist ausgeräumt.
@@ -122,7 +122,7 @@ Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
## Was bewusst gleich bleibt
- **Alle 58 RLS-Policies**, unverändert
- **Alle 21 RLS-Policies**, unverändert
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
- Die Geschäftslogik in den RPCs

View File

@@ -1,5 +1,23 @@
# Datenmodell
> **Veraltet — siehe `docs/datenkatalog.md`.**
>
> Dieses Dokument beschreibt den Stand vor zwei Umbauten und stimmt in
> wesentlichen Teilen nicht mehr:
>
> - Die Org-Tabellen `divisions` / `departments` / `teams` und die Tabelle
> `positions` gibt es nicht mehr. An ihrer Stelle steht das SAP-OM-Modell
> (`org_units`, `jobs`, `om_positions`, `position_assignments`) mit
> zeitabhängigen Zuordnungen.
> - Supabase Auth, der anon key und die Service-Role sind weg. Angemeldet
> wird über Auth.js gegen Entra ID, die Konten stehen in `app_users`, und
> der Zugriff läuft über eine Rolle ohne `BYPASSRLS`.
> - Die Zahl der RLS-Policies ist 21, nicht 58.
>
> Der Datenkatalog wurde aus der laufenden Datenbank erzeugt und gilt. Was
> hier noch stimmt — die Grundprinzipien und der Abschnitt zu den
> effective-dated changes — steht dort ebenfalls.
Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`),
nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die
Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei

421
docs/datenkatalog.md Normal file
View File

@@ -0,0 +1,421 @@
# Datenkatalog
Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden
Datenbank am **13.08.2026**.
Die Wahrheit steht in `supabase/migrations/` (48 Dateien). Dieses Dokument
ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem
Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und
Prüfbedingungen stammen aus `information_schema` und `pg_catalog`. Wo unten
eine Regel in Worten steht, steht daneben, aus welcher `CHECK`-Bedingung sie
kommt.
> **Nicht verwechseln mit `docs/data-model.md`.** Das Dokument beschreibt den
> Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt
> Tabellen (`divisions`, `departments`, `teams`, `positions`), die es nicht
> mehr gibt. Bei Widerspruch gilt dieser Katalog.
| | |
|---|---|
| Tabellen | 15 |
| Spalten | 142 |
| Aufzählungstypen | 10 |
| Sichten (Views) | 0 |
| Eigene SQL-Funktionen | 38 (plus 31 aus der Erweiterung `pg_trgm`) |
| RLS-Policies | 21, auf jeder Tabelle mindestens eine |
---
## Wie das Modell gedacht ist
Drei Entscheidungen erklären fast jede Eigenheit weiter unten.
**Person und Planstelle sind zwei Dinge.** Eine Person (`employees`) hat
keine Spalte „Abteilung". Sie sitzt auf einer Planstelle (`om_positions`),
und die Planstelle hängt in einer Organisationseinheit (`org_units`). Wo
jemand arbeitet, ist damit nicht ein Feld, sondern ein Weg über zwei
Tabellen. Der Preis ist ein Join; dafür lässt sich eine Planstelle
ausschreiben, bevor jemand darauf sitzt, und eine Person wechseln, ohne dass
die Stelle verschwindet.
**Zuordnungen haben ein Von und ein Bis.** `position_assignments`,
`om_positions` und `org_units` tragen alle `valid_from` / `valid_to` als
halboffenes Intervall: `valid_from` gehört dazu, `valid_to` nicht mehr. Eine
Zuordnung, die heute endet, hat `valid_to = heute` und gilt heute schon nicht
mehr. Deshalb kann jede Auswertung einen Stichtag haben, auch einen in der
Vergangenheit, und deshalb ist eine Versetzung zum Ersten des nächsten Monats
kein Termin im Kalender, sondern eine Zeile, die erst dann greift.
**Geschichte wird geschrieben, nicht überschrieben.** `employee_history` und
`audit_log` haben keine Update- und keine Delete-Policy — RLS lässt nur
`select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine
geänderte Zeile.
Zwei Ausnahmen gibt es, und sie sind eng: `delete_history_entry` nimmt eine
irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung
zurück, `update_history_entry` berichtigt Wert und Datum einer solchen. Die
Policies bleiben dabei unangetastet — beide laufen als `SECURITY DEFINER` an
ihnen vorbei und prüfen die Berechtigung selbst. Das Protokoll behält den
Vorgang, dort verschwindet nichts.
Die Namen der OM-Tabellen sind nicht zufällig gewählt: `org_units` ist der
SAP-Objekttyp O, `jobs` ist C, `om_positions` ist S, `employees` ist P, und
`position_assignments` ist die Verknüpfung A008 („Inhaber ist"). Das Flag
`is_chief` entspricht A012 („ist Leiter von"). Wer das Modell aus SAP kennt,
findet sich wieder; wer nicht, verliert nichts.
```
org_units ──parent_id──┐ (rekursiv: Gesellschaft › Bereich › Abteilung › Team)
│ │
└──────────────────┘
▲
│ org_unit_id
om_positions ──job_id──► jobs
▲
│ position_id
position_assignments ──employee_id──► employees ──location_id──► locations
▲
├── employee_history (Ereignisse)
├── employee_dependents (Angehörige)
├── employee_notes (HR-Notizen)
├── pending_org_changes (wirkt später)
└── audit_log (wer/wann/was)
app_users ──1:1──► profiles (Anmeldung ist nicht Berechtigung)
```
---
## Personen
### `employees` — 46 Spalten
Die Person selbst: Stammdaten, Vertrag, Status. **Nicht** die Organisation —
die kommt über die Planstelle.
| Spalte | Typ | Null | Vorgabe | Bedeutung |
|---|---|---|---|---|
| `id` | uuid | – | `gen_random_uuid()` | Schlüssel |
| `personnel_number` | int4 | – | – | **Eindeutig.** Wird eingegeben, nicht vergeben — sie muss mit Loga/Interflex übereinstimmen |
| `first_name`, `last_name` | text | – | – | |
| `gender` | `gender_type` | – | – | `m` / `w` |
| `birth_date` | date | – | – | |
| `sv_nummer` | text | ja | – | Österreichische SV-Nummer; gegen Prüfziffer *und* Geburtsdatum geprüft (Trigger `fn_validate_employee_svnr`) |
| `nationality` | text | – | `'Österreich'` | |
| `address`, `postal_code`, `city`, `address_country` | text | ja | – | Wohnanschrift |
| `email` | text | ja | – | **Private** E-Mail. Freiwillig, aber eindeutig, wenn angegeben |
| `phone` | text | ja | – | **Private** Telefonnummer. Freiwillig |
| `job_title` | text | – | – | Anzeigetitel. Der verbindliche Titel steht am Job der Planstelle |
| `location_id` | uuid | – | – | → `locations` |
| `employment_type` | `employment_type` | – | `Vollzeit` | |
| `weekly_hours` | numeric | – | `38.5` | An `employment_type` gekoppelt, siehe Regeln |
| `contract_type` | `contract_type` | – | `unbefristet` | |
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
| `paygrade` | `paygrade_type` | – | `B` | A–F |
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
| `entry_date` | date | – | – | |
| `exit_date`, `exit_reason` | date/text | ja | – | |
| `karenz_start_date`, `karenz_return_date` | date | ja | – | Laufende Langzeitabwesenheit |
| `absence_type` | text | ja | – | Art der Abwesenheit; wird bei der Rückkehr geleert. 13 erlaubte Werte |
| `is_betriebsrat`, `is_laterale_fuehrung`, `is_c_level` | bool | – | `false` | |
| `has_dienstwagen` | bool | – | `false` | |
| `dienstwagen_art` | text | ja | – | `Verbrenner` / `Elektro`; nur zusammen mit `has_dienstwagen` |
| `emergency_contact_name`, `_phone`, `_relation` | text | ja | – | Daten einer dritten Person, nur für den Notfall erhoben |
| `title_prefix`, `title_suffix` | text[] | – | `{}` | Akademische Grade, gegen feste Listen geprüft |
| `avatar_color` | text | ja | – | Darstellung |
| `monthly_salary_gross` | numeric | ja | – | **Stillgelegt.** Gehalt ist ausserhalb des Funktionsumfangs; keine Funktion liest oder schreibt die Spalte mehr. Steht nur noch da, falls Altdaten drin sind |
| `created_at`, `updated_at` | timestamptz | – | `now()` | `updated_at` per Trigger |
**Regeln, die die Datenbank durchsetzt:**
- *Vollzeit heisst 38,5 Stunden; Teilzeit heisst mehr als 0 und weniger als
38,5.* Ein Vollzeitvertrag mit 30 Stunden lässt sich nicht speichern
(`chk_weekly_hours`).
- *Befristet ohne Enddatum gibt es nicht* (`chk_befristet_end`).
- *Austritt nicht vor Eintritt*, *Rückkehr nicht vor Eintritt*
(`chk_exit_after_entry`, `chk_karenz_return_after_entry`).
- *Dienstwagen und Antriebsart gehören zusammen* — beides oder keins
(`chk_dienstwagen_art`). Die Bedingung nennt den Fall ohne Wagen
ausdrücklich, weil `art in (…)` bei `null` weder wahr noch falsch ergibt
und die Regel sonst genau das durchgelassen hätte, was sie verhindern soll.
- *Notfallkontakt: Name und Telefon gemeinsam oder gar nicht*, und keiner der
beiden leer (`chk_emergency_contact`).
- *Arbeitstage nur aus Mo–So und mindestens einer* (`chk_work_days_valid`).
- *Titel nur aus den bekannten Listen* — 10 vorangestellte, 12 nachgestellte
(`chk_title_prefix_valid`, `chk_title_suffix_valid`).
- *Abwesenheitsart nur aus den 13 bekannten* (`chk_absence_type`).
### `employee_history` — die Zeitleiste
Eine Zeile je Ereignis: `employee_id`, `event_date`, `event_type`
(Aufzählung, 11 Werte), `description`. Ein Trigger verhindert Ereignisse vor
dem Eintrittsdatum (`fn_check_history_not_before_entry`). Nur einfügen und
lesen — kein Ändern, kein Löschen.
### `employee_dependents` — Angehörige
`first_name`, `last_name`, `relationship` (Ehepartner:in / Lebenspartner:in /
Kind / Sonstige), `birth_date`, optional `sv_nummer`. Ändern heisst löschen
und neu anlegen; ein In-place-Update gibt es nicht. Beim Löschen der Person
verschwinden sie mit (`on delete cascade`).
### `employee_notes` — HR-Notizen
`category` (Allgemein / Vertraulich / Personalgespräch / Wiedervorlage /
Lob / Anerkennung), `note_text`, optional `due_date` für die Wiedervorlage,
dazu `done` / `done_at` / `done_by`.
Bewusst **nicht** auf die verfassende Person eingeschränkt: jede aktive
HR-Person sieht jede offene Notiz. „Meine Notizen" ist ein gemeinsames
Postfach, kein privates.
---
## Organisation
### `org_units` — Einheiten (SAP-Objekttyp O)
`org_number` (eindeutig), `name`, `parent_id` (rekursiv), `unit_type`
(Gesellschaft / Bereich / Abteilung / Team), `valid_from` / `valid_to`.
`unit_type` ist ein Etikett für die Anzeige, keine Struktur — die Struktur
ist `parent_id`. Eine Abteilung unter einer Abteilung wäre erlaubt. Was die
Datenbank verhindert, ist nur, dass eine Einheit ihr eigenes Elternteil wird
(`chk_org_unit_not_own_parent`); tiefere Zyklen fängt sie nicht ab.
### `jobs` — Tätigkeiten (Objekttyp C)
`code` und `title`, beide eindeutig. Ein schlanker Katalog: die Planstelle
verweist darauf, statt den Titel abzuschreiben.
### `om_positions` — Planstellen (Objekttyp S)
`position_number` (eindeutig), `org_unit_id`, `job_id`, `is_chief`,
`valid_from` / `valid_to`.
Die Nummern haben die Form `6` + sieben Ziffern, weil
`next_position_number()` sie so erzeugt — erzwungen wird das Format aber
nicht: an der Spalte hängt nur Eindeutigkeit. Wer von aussen eine Nummer
einträgt, kann eine andere Form wählen, und die Zählfunktion übergeht sie
dann (sie sucht ihr Maximum nur unter `^6[0-9]{7}$`).
`is_chief` markiert die Leitungsstelle einer Einheit — daraus entsteht die
Führungslinie, nicht aus einem Feld „Vorgesetzte:r" an der Person. Eine
Planstelle lässt sich nur besetzen, solange sie gültig ist.
### `position_assignments` — Besetzungen (Verknüpfung A008)
`position_id`, `employee_id`, `valid_from` / `valid_to`. Diese Tabelle
beantwortet „wer sass wann wo" — die einzige Stelle, an der das steht.
### `locations` — Standorte
`name` (eindeutig) und `country`, beschränkt auf Österreich, Deutschland,
Tschechien und Slowenien.
---
## Ablauf und Nachweis
### `pending_org_changes` — was später wirkt
`change_type` (transfer / promotion / karenz_start / karenz_return /
contract_change / reorg / dependent_add / dependent_remove),
`effective_date`, `payload` (JSONB), `status` (pending / applied /
cancelled).
Der Weg ist `pending` → `applied`. Die Auswahl filtert immer auf `pending`,
ein zweiter Lauf wirkt also nicht doppelt. Verarbeitet wird täglich von
`apply_due_pending_changes()`.
### `audit_log` — wer, wann, was, an wem
`occurred_at`, `actor_user_id` + `actor_name`, `action`, `target_label`,
`target_employee_id`, `details`, und `changes` als JSONB in der Form
`[{feld, vorher, nachher}]` — daher die aufklappbare Detailansicht in der
Oberfläche. Bei Einträgen von vor der entsprechenden Migration ist `changes`
null.
Geschrieben wird ausschliesslich aus den SQL-Funktionen heraus, in derselben
Transaktion wie die Änderung selbst. Das ist der Punkt: ein fehlgeschlagener
Log-Eintrag lässt die ganze Änderung scheitern, statt still eine Änderung
ohne Nachweis zu hinterlassen. Einen Helfer im Anwendungscode gibt es nicht
und sollte es nicht geben — das wäre eine zweite, nicht atomare Quelle.
Dass `actor_name` als Text mitgeschrieben wird und nicht nur die Kennung: der
Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet.
---
## Zugang
### `app_users`
Ersetzt `auth.users` aus der Supabase-Zeit. `external_id` ist die `oid` aus
Entra ID — **nicht** die E-Mail-Adresse, die kann sich ändern. Angelegt wird
die Zeile bei der ersten Anmeldung durch `app_upsert_user()`.
### `profiles`
Eine Zeile je Konto, gleicher Schlüssel wie `app_users`. `role` ist per
Prüfbedingung auf den einen Wert `'hr'` festgenagelt, `is_active` steht
anfangs auf `false`.
**Anmelden können heisst nichts.** Wer sich mit dem Firmenkonto anmeldet,
bekommt eine `app_users`-Zeile und kommt trotzdem an keine einzige
Personalzeile, solange `profiles.is_active` nicht gesetzt ist. Die Freigabe
ist ein bewusster zweiter Schritt.
### `hire_drafts`, `saved_reports`
Zwischenstand des Einstellungsassistenten (`payload` JSONB, `step`) und
gespeicherte Berichtskonfigurationen. Beide sind auf die anlegende Person
eingeschränkt.
---
## Aufzählungstypen
| Typ | Werte |
|---|---|
| `employment_status` | Aktiv, Karenz, Geplant, Ausgetreten |
| `employment_type` | Vollzeit, Teilzeit |
| `contract_type` | unbefristet, befristet |
| `worker_type` | Angestellte:r, Arbeiter:in |
| `collective_agreement` | Handel, Süßwaren |
| `paygrade_type` | A, B, C, D, E, F |
| `source_type` | Intern, Extern |
| `gender_type` | m, w |
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |
| `history_event_type` | Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr |
`Karenz` heisst in der Oberfläche „Langzeitabwesenheit" — der gespeicherte
Wert wurde beim Umbenennen bewusst nicht angefasst, die Beschriftung folgt
dem neuen Begriff.
Nicht als Aufzählungstyp, sondern als Prüfbedingung auf einer Textspalte
gelöst: Abwesenheitsart, Antriebsart, Verhältnis von Angehörigen,
Notizkategorie, Änderungsart, Land. Der praktische Unterschied: eine
Prüfbedingung lässt sich in einer Migration ändern, ein Aufzählungstyp nur
erweitern.
---
## Die SQL-Schnittstelle
Änderungen laufen nicht über `insert`/`update` aus der Anwendung, sondern
über Funktionen. Jede schreibt ihren Nachweis und ihre Historie in derselben
Transaktion mit.
**Personal:** `hire_employee`, `rehire_employee`, `terminate_employee`,
`transfer_employee`, `promote_employee`, `change_employee_data`,
`start_karenz`, `adjust_karenz_return`, `record_karenz_return`
**Planstellen:** `create_position`, `update_position`, `delete_position`,
`next_position_number`
**Historie:** `delete_history_entry` nimmt eine irrtümliche Stammdaten- oder
Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein
späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile.
`update_history_entry` berichtigt stattdessen Wert und Datum — das „vorher"
bleibt unangetastet, und der heutige Stand wird je Feld aus dem jüngsten
Eintrag abgeleitet, der es trägt. Beide sind der einzige Weg an den fehlenden
`update`- und `delete`-Policies vorbei, deshalb `SECURITY DEFINER` und mit
`require_hr_admin()` davor. `app_feld_karte` liefert beiden die Zuordnung
Beschriftung → Spalte und Typ.
**Umfeld:** `add_employee_dependent`, `delete_employee_dependent`,
`add_employee_note`, `complete_employee_note`
**Auswertung:** `om_reporting_lines(p_as_of)` — löst zum Stichtag auf, wer an
wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben
`formal_manager_id`)
**Zugang und Nachweis:** `is_hr_user`, `app_current_user_id`,
`app_upsert_user`, `current_actor_name`, `app_aenderung`,
`app_aenderungsfelder`
**Automatik:** `apply_due_pending_changes` (täglich), `rls_auto_enable`
(hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS),
die vier `fn_*`-Trigger, `is_valid_svnr`
Sechs Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer
Eigentümerin statt der aufrufenden Person: `is_hr_user`,
`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes`,
`delete_history_entry` und `update_history_entry`. Die ersten drei müssen es
sein, weil sie sonst gegen dieselben Policies liefen, die sie gerade auswerten
sollen — eine Rekursion. Die vierte läuft ohne angemeldete Person, es gibt ja
nur den Zeitplan. Die letzten beiden müssen an `employee_history` schreiben,
wo es absichtlich weder eine `update`- noch eine `delete`-Policy gibt; beide
prüfen die Berechtigung deshalb selbst, in ihrer ersten Zeile.
**Der Türsteher:** `require_hr_admin()` steht am Anfang von **16**
Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und
liefert damit eine lesbare Meldung statt einer nackten RLS-Verletzung. Der
Name täuscht: ein Admin-Rollenmodell gibt es nicht, `is_hr_admin()` ruft
schlicht `is_hr_user()` auf. Die Schranke selbst bleiben die Policies.
**Übrig geblieben:** `generate_company_email` steht noch in der Datenbank,
wird aber von nichts mehr gerufen — sie stammt aus der Zeit, als eine
Firmenadresse automatisch vergeben wurde; heute ist `employees.email` die
private Adresse und freiwillig.
---
## Zugriffsschutz
Auf **jeder** der 15 Tabellen ist Row Level Security aktiv, zusammen 21
Policies. Fast alle prüfen dasselbe: `is_hr_user()` — also `profiles.role =
'hr'` **und** `is_active`. Nachgereicht wird das nicht: der Ereignis-Trigger
`ensure_rls` schaltet RLS bei jeder neu angelegten Tabelle sofort ein.
Die Prüfung hängt an einer Sitzungsvariablen (`app.user_id`), die
`withUser()` als erste Anweisung jeder Transaktion setzt — transaktionslokal,
damit sie nicht an der Verbindung kleben bleibt und die nächste Anfrage aus
dem Pool mit fremder Kennung läuft.
Wie wirksam das ist, zeigt sich beim Erzeugen dieses Katalogs: die Verbindung
lief ohne Sitzungskontext, und **jede** Tabelle lieferte null Zeilen — bei
vollständig vorhandenen Strukturdaten. Nicht „alles", nicht ein Fehler,
sondern nichts. Die Anwendung verbindet sich ausserdem als `alpenwerk_app` —
eine Rolle ohne `BYPASSRLS` und ohne Superuser-Recht; es gibt also keinen Weg
daran vorbei, auch nicht versehentlich.
Wo das Muster abweicht:
- `audit_log` und `employee_history` haben je zwei Policies — lesen und
einfügen, getrennt, und kein Ändern oder Löschen. Das ist die
Unveränderlichkeit, technisch durchgesetzt.
- `profiles` hat vier, weil dort auch die Freischaltung anderer Konten
passiert — und eine davon lässt jede Person die *eigene* Zeile lesen, auch
ohne Freischaltung. Sonst könnte niemand erfahren, warum er nicht
hineinkommt.
- `app_users` ebenso: die eigene Zeile oder HR.
- `hire_drafts` und `saved_reports` verlangen zusätzlich, dass die Zeile der
anfragenden Person gehört.
- `locations` trennt Lesen und Schreiben in zwei Policies, prüft aber beide
Male dasselbe.
---
## Was hier nicht steht
**Gehalt.** `monthly_salary_gross` ist stillgelegt und wird von keiner
Funktion mehr angefasst. Gehaltsdaten leben in Loga.
**Zeitwirtschaft.** Kommen und Gehen, Urlaubskonten, Krankenstände als
Einzelfälle — das ist Interflex. Hier steht nur die dauerhafte
Langzeitabwesenheit, weil sie die Führungslinie verschiebt.
**Bewerbungen.** Eine offene Planstelle ist hier eine Planstelle ohne
Besetzung, mehr nicht.
---
*Erzeugt aus dem Systemkatalog der laufenden Datenbank. Nach strukturellen
Änderungen gehört dieses Dokument nachgezogen — am ehrlichsten, indem es neu
aus der Datenbank erzeugt wird, statt es von Hand zu pflegen.*

View File

@@ -6,7 +6,7 @@ keinen Anmeldedienst eines Anbieters mehr dazwischen.
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
vor nur 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
damit bleiben `is_hr_user()` und alle 21 RLS-Policies unverändert gültig. Die
Sicherheitsgrenze wandert nicht in den Anwendungscode.
## Einrichtung im Entra-Mandanten

View File

@@ -7,18 +7,24 @@ import type { EmploymentStatus } from "./supabase/types";
// a label change (see supabase/migrations/*_absence_type.sql). So the value
// is mapped to its display name here, in the one place the UI reads it from.
// Nur echte Abwesenheiten. Eine Teilzeit ist keine: wer in Bildungs-,
// Pflege-, Eltern- oder Wiedereingliederungsteilzeit ist, arbeitet — nur
// weniger. Als „Langzeitabwesenheit" geführt, verschwand die Person aus dem
// Bestand, verlor ihre Berichtslinie an eine Vertretung und zählte in
// Auswertungen nicht mehr mit, obwohl sie jede Woche im Haus war.
//
// Die vier sind deshalb hierher gewandert, wo sie hingehören: zur
// Stundenänderung (siehe STUNDEN_GRUENDE) und zur Rückkehr aus einer
// Abwesenheit (RUECKKEHR_GRUENDE).
export const ABSENCE_TYPES = [
"Wochenhilfe (Mutterschutz)",
"Elternkarenz (inkl. Väterkarenz)",
"Papamonat",
"Bildungskarenz",
"Bildungsteilzeit",
"Präsenzdienst",
"Zivildienst",
"Langer Krankenstand",
"Wiedereingliederungsteilzeit",
"Pflegekarenz",
"Pflegeteilzeit",
"Familienhospizkarenz",
"Sabbatical",
] as const;
@@ -29,6 +35,25 @@ export function isAbsenceType(value: string | null | undefined): value is Absenc
return ABSENCE_TYPES.includes(value as AbsenceType);
}
/**
* Warum jemand nach einer Abwesenheit mit weniger Stunden zurückkommt.
*
* Beides sind Ansprüche, keine freien Vereinbarungen — und beide beginnen
* typischerweise genau dann, wenn die Abwesenheit endet.
*/
export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzeit"] as const;
/**
* Warum sich die Wochenstunden ändern.
*
* Der erste Wert ist der Normalfall; die beiden anderen sind gesetzlich
* geregelte Teilzeiten, die man später wiederfinden können muss — im
* Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken.
*/
export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const;
export type StundenGrund = (typeof STUNDEN_GRUENDE)[number];
/** Display names for the stored status values. */
const STATUS_LABELS: Record<EmploymentStatus, string> = {
Aktiv: "Aktiv",

View File

@@ -11,7 +11,7 @@ import { auth } from "@/auth";
// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie
// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id.
// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in
// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts.
// der Datenbank erwartet, und die 21 RLS-Policies merken vom Wechsel nichts.
export async function currentUserId(): Promise<string | null> {
const session = await auth();

107
lib/cost-centers.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { Tx } from "./db";
import { jsonArrayFrom } from "./db/json";
import { todayIso } from "./format";
import type { OrgEb } from "./org";
// Die Kostenstelle einer Planstelle — zum Stichtag, nicht „aktuell".
//
// Sie hängt an der Planstelle, nicht an der Person: der Sitz kostet Geld, auch
// wenn niemand darauf sitzt. Genau das ist die Frage bei einer Vakanz.
//
// Und sie hat einen Zeitraum, weil eine Umkontierung ein Ereignis mit Stichtag
// ist. Wer nach den Kosten des Vorjahres fragt, muss die Kostenstelle von
// damals bekommen — sonst verändert jede Umkontierung rückwirkend jede alte
// Auswertung, und das merkt niemand.
export type Kostenstelle = {
id: string;
code: string;
name: string;
};
export type KontierungsZeile = {
position_id: string;
cost_center_id: string;
code: string;
name: string;
valid_from: string;
valid_to: string | null;
};
/**
* Die Kontierungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
* eine Seite ohnehin stellt (lib/db/json.ts).
*
* Ohne `positionIds` alle. Gefiltert wird nicht nach Stichtag: die Auswahl
* trifft `kontierungZum` im Speicher, damit dieselben Zeilen für mehrere
* Stichtage reichen und die Abfrage eine bleibt.
*/
export function kontierungenAbfrage(eb: OrgEb, positionIds?: string[]) {
const q = eb
.selectFrom("position_cost_centers as z")
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
.orderBy("z.position_id")
.orderBy("z.valid_from");
return positionIds ? q.where("z.position_id", "in", positionIds) : q;
}
/** Alle Kostenstellen zur Auswahl — abgelaufene bleiben draussen. */
export function kostenstellenAbfrage(eb: OrgEb, asOf: string = todayIso()) {
return eb
.selectFrom("cost_centers")
.select(["id", "code", "name"])
.where((e) => e.or([e("valid_to", "is", null), e("valid_to", ">", asOf)]))
.orderBy("code");
}
/**
* Welche Kostenstelle je Planstelle am Stichtag galt.
*
* Halboffen [valid_from, valid_to): der letzte Tag gehört schon zur nächsten
* Kontierung. Dieselbe Regel wie bei den Besetzungen — eine zweite Auslegung
* desselben Zeitraummodells wäre der sichere Weg zu zwei Antworten auf
* dieselbe Frage.
*/
export function kontierungZum(rows: KontierungsZeile[], asOf: string): Map<string, Kostenstelle> {
const out = new Map<string, Kostenstelle>();
for (const r of rows) {
if (r.valid_from > asOf) continue;
if (r.valid_to !== null && r.valid_to <= asOf) continue;
out.set(r.position_id, { id: r.cost_center_id, code: r.code, name: r.name });
}
return out;
}
/**
* Wie kontierungZum, aber für eine einzelne Planstelle und einen Stichtag,
* der nicht heute sein muss.
*
* Nötig für Planstellen, die erst entstehen: ihre Kontierung beginnt mit
* ihnen. Zu heute gefragt gäbe es keine — und die Vakanzliste, die künftige
* Stellen bewusst zeigt, stünde für genau diese ohne Kostenstelle da. Die
* Frage lautet dort nicht „wer zahlt heute", sondern „wer zahlt, wenn es
* losgeht".
*/
export function kontierungAm(rows: KontierungsZeile[], positionId: string, asOf: string): Kostenstelle | null {
for (const r of rows) {
if (r.position_id !== positionId) continue;
if (r.valid_from > asOf) continue;
if (r.valid_to !== null && r.valid_to <= asOf) continue;
return { id: r.cost_center_id, code: r.code, name: r.name };
}
return null;
}
/** Der bequeme Weg für Aufrufer ohne eigene Abfrage. */
export async function loadKontierungen(
tx: Tx,
{ asOf, positionIds }: { asOf: string; positionIds?: string[] }
): Promise<Map<string, Kostenstelle>> {
if (positionIds?.length === 0) return new Map();
const { rows } = await tx
.selectNoFrom((eb) => [jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("rows")])
.executeTakeFirstOrThrow();
return kontierungZum(rows as KontierungsZeile[], asOf);
}

View File

@@ -197,3 +197,63 @@ export const UN_COUNTRIES: string[] = [
"Zentralafrikanische Republik",
"Zypern",
].sort((a, b) => a.localeCompare(b, "de"));
// Wer keinen Aufenthaltstitel braucht.
//
// EU-27, dazu Island, Liechtenstein und Norwegen (EWR) sowie die Schweiz.
// Für diese Staatsangehörigen gilt Freizügigkeit; ein Aufenthaltstitel ist
// weder nötig noch zu erfassen. Für alle übrigen ist er es, und dann will HR
// wissen, bis wann er läuft.
//
// Die Liste steht hier und nicht in der Datenbank: eine Prüfbedingung, die
// Staatsbürgerschaft und Titel koppelt, würde bei jeder Korrektur der
// Staatsbürgerschaft zuschlagen — und Beitritte müssten als Migration
// nachgezogen werden, statt als Zeile in einer Liste.
export const FREIZUEGIGKEIT: readonly string[] = [
// EU
"Belgien",
"Bulgarien",
"Dänemark",
"Deutschland",
"Estland",
"Finnland",
"Frankreich",
"Griechenland",
"Irland",
"Italien",
"Kroatien",
"Lettland",
"Litauen",
"Luxemburg",
"Malta",
"Niederlande",
"Österreich",
"Polen",
"Portugal",
"Rumänien",
"Schweden",
"Slowakei",
"Slowenien",
"Spanien",
"Tschechien",
"Ungarn",
"Zypern",
// EWR
"Island",
"Liechtenstein",
"Norwegen",
// Bilaterale Freizügigkeit
"Schweiz",
];
/**
* Braucht jemand mit dieser Staatsbürgerschaft einen Aufenthaltstitel?
*
* Bei leerer Angabe: nein. Eine fehlende Staatsbürgerschaft ist kein Anlass,
* jemandem einen Titel abzuverlangen — sie ist ein Anlass, sie zu erfassen.
*/
export function brauchtAufenthaltstitel(nationality: string | null | undefined): boolean {
const n = nationality?.trim();
if (!n) return false;
return !FREIZUEGIGKEIT.includes(n);
}

159
lib/dashboard-data.ts Normal file
View File

@@ -0,0 +1,159 @@
import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
import { besetzungenAbfrage, pickPlacements } from "./placement";
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
import type { HistoryEventType } from "./supabase/types";
import type { AnstehendArt } from "./dashboard-filter";
// Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn.
//
// Hier und nicht in der Seite, damit sich die Zahl der Rundreisen messen und
// das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente
// aufzubauen.
export type DashboardParams = {
userId: string | null;
today: string;
yearStart: string;
yearEnd: string;
bisIso: string;
arten: AnstehendArt[];
};
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
const zeigt = (art: AnstehendArt) => arten.includes(art);
// Elf Abfragen standen hier in einem Promise.all, das keine
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
// über eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms
// Umlaufzeit war das eine knappe Sekunde Warten für Daten, die zusammen
// keine 200 kB wiegen. Jetzt: eine Rundreise, und eine zweite für die
// offenen Stellen, deren zweite Hälfte vom Ergebnis der ersten abhängt.
// Der Weg dahin steht in lib/db/json.ts.
const countIn = (eb: OrgEb, types: readonly HistoryEventType[]) =>
eb
.selectFrom("employee_history")
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd);
const g = await tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("hire_drafts")
.select(["id", "step", "payload"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("created_by", "=", userId ?? "")
.orderBy("updated_at", "desc")
).as("drafts"),
jsonArrayFrom(
eb
.selectFrom("employees")
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
.orderBy("id")
).as("staffRows"),
// Entries/exits count history events, which is what the linked report
// counts too. `entry_date` would also sweep up rehires, whose event is
// logged as 'Wiedereintritt' — the tile and its destination then showed
// different numbers for the same year.
jsonObjectFrom(countIn(eb, ["Eintritt", "Wiedereintritt"])).as("hiresYtd"),
jsonObjectFrom(countIn(eb, ["Austritt"])).as("exitsYtd"),
...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
jsonArrayFrom(besetzungenAbfrage(eb)).as("placementRows"),
// Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie
// ohnehin nicht, und eine Teilabfrage, deren Ergebnis niemand ansieht,
// ist eine Teilabfrage zu viel.
jsonArrayFrom(
eb
.selectFrom("employees")
.select(["id", "first_name", "last_name", "entry_date"])
.where("status", "=", "Geplant")
.where("entry_date", ">=", today)
.where("entry_date", "<=", bisIso)
.where((e) => e.lit(zeigt("hire")))
).as("upcomingHires"),
jsonArrayFrom(
eb
.selectFrom("employees")
.select(["id", "first_name", "last_name", "exit_date"])
.where("exit_date", "is not", null)
.where("exit_date", ">=", today)
.where("exit_date", "<=", bisIso)
.where((e) => e.lit(zeigt("exit")))
).as("upcomingExits"),
jsonArrayFrom(
eb
.selectFrom("employees")
.select(["id", "first_name", "last_name", "karenz_return_date"])
.where("status", "=", "Karenz")
.where("karenz_return_date", "is not", null)
.where("karenz_return_date", ">=", today)
.where("karenz_return_date", "<=", bisIso)
.where((e) => e.lit(zeigt("return")))
).as("upcomingReturns"),
// Wiedervorlagen. Sie verhalten sich anders als der Rest dieser Karte:
// ein Eintritt am Montag ist am Dienstag vorbei, eine Wiedervorlage
// nicht. Deshalb gibt es hier **keine untere Grenze** — was fällig war
// und nicht abgehakt wurde, bleibt stehen, bis jemand „Erledigt" klickt.
// Eine Aufgabe, die von selbst aus der Liste rutscht, ist eine
// vergessene Aufgabe.
jsonArrayFrom(
eb
.selectFrom("employee_notes as n")
.innerJoin("employees as e", "e.id", "n.employee_id")
.select(["n.id", "n.employee_id", "n.due_date", "n.category", "n.note_text", "e.first_name", "e.last_name"])
.where("n.done", "=", false)
.where("n.due_date", "is not", null)
.where("n.due_date", "<=", bisIso)
.where((x) => x.lit(zeigt("note")))
.orderBy("n.due_date")
).as("upcomingNotes"),
jsonArrayFrom(
eb
.selectFrom("employee_history as h")
.leftJoin("employees as e", "e.id", "h.employee_id")
.select([
"h.id",
"h.employee_id",
"h.event_date",
"h.event_type",
"h.description",
"e.first_name",
"e.last_name",
])
.orderBy("h.event_date", "desc")
.orderBy("h.created_at", "desc")
.limit(10)
).as("history"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
return {
drafts: userId ? g.drafts : [],
staffRows: g.staffRows,
hiresYtd: Number(g.hiresYtd?.anzahl ?? 0),
exitsYtd: Number(g.exitsYtd?.anzahl ?? 0),
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
orgMaps,
placements: pickPlacements(g.placementRows as never, today),
upcomingHires: g.upcomingHires,
upcomingExits: g.upcomingExits,
upcomingReturns: g.upcomingReturns,
upcomingNotes: g.upcomingNotes,
history: g.history,
};
}

47
lib/dashboard-filter.ts Normal file
View File

@@ -0,0 +1,47 @@
// Was auf der Übersicht als „anstehend" gilt — Zeitraum und Art.
//
// Die Auswahl steht in der Adresse, nicht im Browser: die Übersicht wird auf
// dem Server gebaut, und ein Zeitraum von 90 statt 60 Tagen bringt Zeilen ins
// Spiel, die im Browser gar nicht liegen. Nebenbei lässt sich eine so
// eingestellte Seite verschicken und wiederfinden.
export const ZEITRAEUME = [30, 60, 90, 180] as const;
export type Zeitraum = (typeof ZEITRAEUME)[number];
export const STANDARD_ZEITRAUM: Zeitraum = 60;
export const ANSTEHEND_ARTEN = [
{ value: "hire", label: "Eintritt" },
{ value: "exit", label: "Austritt" },
{ value: "return", label: "Rückkehr" },
{ value: "note", label: "Wiedervorlage" },
] as const;
export type AnstehendArt = (typeof ANSTEHEND_ARTEN)[number]["value"];
const ALLE_ARTEN: AnstehendArt[] = ANSTEHEND_ARTEN.map((a) => a.value);
/** Alles, was nicht in der Liste steht, führt auf den Standard zurück. */
export function parseZeitraum(wert: string | undefined): Zeitraum {
const zahl = Number(wert);
return (ZEITRAEUME as readonly number[]).includes(zahl) ? (zahl as Zeitraum) : STANDARD_ZEITRAUM;
}
/**
* Keine Angabe heisst „alle" — nicht „keine". Wer die Übersicht aufruft, ohne
* etwas auszuwählen, will alles sehen; eine leere Karte wäre die falsche
* Antwort auf eine nicht gestellte Frage. Aus demselben Grund führt auch eine
* Auswahl, in der nur Unbekanntes steht, zurück auf alle.
*/
export function parseArten(wert: string | undefined): AnstehendArt[] {
const gewaehlt = (wert ?? "")
.split(",")
.map((s) => s.trim())
.filter((s): s is AnstehendArt => (ALLE_ARTEN as string[]).includes(s));
return gewaehlt.length > 0 ? gewaehlt : ALLE_ARTEN;
}
/** Steht in der Adresse etwas anderes als der Standard? */
export function istEingeschraenkt(zeitraum: Zeitraum, arten: AnstehendArt[]): boolean {
return zeitraum !== STANDARD_ZEITRAUM || arten.length !== ALLE_ARTEN.length;
}

View File

@@ -1,4 +1,5 @@
import "server-only";
import { AsyncLocalStorage } from "node:async_hooks";
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
import { getPool } from "./pool";
import type { Schema } from "./schema";
@@ -7,7 +8,7 @@ import type { Schema } from "./schema";
//
// ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══
//
// Die Zugriffsrechte liegen in der Datenbank: 58 RLS-Policies rufen
// Die Zugriffsrechte liegen in der Datenbank: 21 RLS-Policies rufen
// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase,
// sondern `current_setting('app.user_id')` — eine Sitzungsvariable.
//
@@ -36,8 +37,33 @@ import type { Schema } from "./schema";
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
// Moduls noch keine Zugangsdaten — siehe getPool().
// ═══ Wie viele Rundreisen eine Anfrage kostet ═══
//
// Eine Transaktion hängt an einer Verbindung, und über eine Verbindung laufen
// Abfragen nacheinander — auch die in einem Promise.all. Bei rund 36 ms
// Umlaufzeit zur Datenbank ist die Zahl der Abfragen deshalb *die* Kennzahl
// für die Ladezeit einer Seite, und zwar eine, die man nicht schätzen muss.
//
// Sie wird darum mitgezählt und im Entwicklungsbetrieb gemeldet, sobald eine
// Transaktion viele davon braucht. Ohne diese Meldung wächst so etwas
// unbemerkt: jede neue Kachel bringt ihre eigene Abfrage mit, und dass die
// Seite langsamer wird, merkt man erst, wenn es alle merken.
const zaehler = new AsyncLocalStorage<{ abfragen: number }>();
export function zaehleAbfragen(): { abfragen: number } | undefined {
return zaehler.getStore();
}
/** Ab wann eine Transaktion im Entwicklungsbetrieb gemeldet wird. */
const MELDESCHWELLE = Number(process.env.DB_QUERY_WARN ?? 6);
const db = new Kysely<Schema>({
dialect: new PostgresDialect({ pool: async () => getPool() }),
log: (event) => {
const store = zaehler.getStore();
if (store) store.abfragen++;
if (event.level === "error") console.error("Abfrage fehlgeschlagen:", event.error);
},
});
export type Tx = Transaction<Schema>;
@@ -49,11 +75,26 @@ export type Tx = Transaction<Schema>;
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
*/
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
return db.transaction().execute(async (tx) => {
// Erste Anweisung der Transaktion, vor allem anderen.
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
return fn(tx);
});
const stand = { abfragen: 0 };
const start = performance.now();
try {
return await zaehler.run(stand, () =>
db.transaction().execute(async (tx) => {
// Erste Anweisung der Transaktion, vor allem anderen.
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
return fn(tx);
})
);
} finally {
// Nur im Entwicklungsbetrieb: in der Produktion gehörte das in die
// Ablaufverfolgung, nicht auf die Konsole.
if (process.env.NODE_ENV !== "production" && stand.abfragen > MELDESCHWELLE) {
console.warn(
`[db] ${stand.abfragen} Abfragen in einer Transaktion, ${Math.round(performance.now() - start)} ms — ` +
`sie laufen nacheinander über eine Verbindung. Bündeln: siehe lib/db/json.ts.`
);
}
}
}
/**

69
lib/db/json.ts Normal file
View File

@@ -0,0 +1,69 @@
// Kein `server-only` hier, anders als in ./index.ts und ./pool.ts: diese Datei
// baut nur Abfragen zusammen und hält weder Verbindung noch Zugangsdaten. Mit
// der Sperre wären die reinen Tests von lib/org.ts nicht mehr ladbar, obwohl
// dort nur ein Baum aus Zeilen gebaut wird.
import { sql, type Expression, type RawBuilder } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
// Mehrere unabhängige Lesevorgänge in **einer** Rundreise.
//
// ═══ Warum das nötig ist ═══
//
// Eine Transaktion hängt an genau einer Verbindung, und über eine Verbindung
// laufen Abfragen nacheinander — auch die, die in einem Promise.all stehen.
// Der Treiber stellt sie in eine Schlange. `Promise.all` sieht nach
// Gleichzeitigkeit aus und ist hier keine.
//
// Gemessen an der echten Datenbank: die Umlaufzeit beträgt rund 36 ms, zehn
// belanglose `select 1` über eine Verbindung brauchen 343 ms, über zehn
// Verbindungen 39 ms. Der Aufbau der Übersichtsseite — zehn Abfragen, die
// zusammen keine 200 kB liefern — kostete so knapp eine Sekunde, fast
// ausschliesslich Warten.
//
// Mehr Verbindungen sind trotzdem nicht die Antwort: der Sitzungskontext für
// RLS gilt je Transaktion (siehe ./index.ts), und mehrere Transaktionen je
// Anfrage vervielfachen die Verbindungen, die die Datenbank zulässt. Also
// weniger Rundreisen statt mehr Leitungen: Postgres kann jede Teilabfrage als
// JSON-Spalte in *einem* Ergebnis liefern.
//
// const { einheiten, standorte } = await tx
// .selectNoFrom((eb) => [
// jsonArrayFrom(eb.selectFrom("org_units").select([...])).as("einheiten"),
// jsonArrayFrom(eb.selectFrom("locations").selectAll()).as("standorte"),
// ])
// .executeTakeFirstOrThrow();
//
// Typisiert wie jede andere Kysely-Abfrage, mit Parametern, ohne Handarbeit
// an der Zeichenkette.
//
// ═══ Die eine Falle ═══
//
// Innerhalb von json_agg formatiert Postgres die Werte selbst, und der
// Treiber kommt nicht mehr daran (lib/db/pool.ts stellt ihn dort auf die
// Formen um, die die Typen beschreiben). Für die meisten Typen macht das
// nichts — im Gegenteil:
//
// date → "2022-03-30" wie ausserhalb
// numeric → 38.5 wie ausserhalb
// uuid → Zeichenkette wie ausserhalb
// timestamptz → "2026-08-03T12:08:06.272938+00:00"
// ← **anders**: ausserhalb "…272Z"
//
// Der Unterschied ist nicht kosmetisch. Zeitstempel werden im Projekt als
// Zeichenketten verglichen (lib/history.ts entscheidet daran, was später
// passiert ist), und "+00:00" sortiert gegen "Z" falsch herum. Deshalb geht
// **jede** timestamptz-Spalte in einer gebündelten Abfrage durch zeitstempel().
export { jsonArrayFrom, jsonObjectFrom };
/**
* Eine timestamptz-Spalte in der Form, die der Treiber ausserhalb von JSON
* liefert — ISO-8601 in UTC, auf Millisekunden gekürzt.
*
* Ohne das käme aus einer gebündelten Abfrage eine andere Zeichenkette als
* aus derselben Abfrage einzeln gestellt.
*/
export function zeitstempel(spalte: Expression<unknown> | string): RawBuilder<string> {
const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte;
return sql<string>`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
}

17
lib/dienstwagen.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { DienstwagenArt } from "./supabase/types";
// Wie ein Dienstwagen benannt wird.
//
// Gespeichert steht „Elektro"; gesprochen wird von einem E-KFZ, und danach
// wird auch gefragt. Die Beschriftung stand an drei Stellen einzeln — im
// Auswahlfeld, in der Zusammenfassung des Einstellungsassistenten und auf dem
// Vertragsblatt. Drei Kopien einer Beschriftung laufen auseinander, und dann
// heisst dasselbe Auto je nach Bildschirm anders.
export function dienstwagenLabel(art: DienstwagenArt | null | undefined): string {
if (art === "Elektro") return "E-KFZ";
if (art === "Verbrenner") return "Verbrenner";
// Der CHECK erlaubt es nicht, aber gelesen wird auch, was jemand über den
// Import hineingelegt hat.
return art ?? "–";
}

137
lib/employee-detail-data.ts Normal file
View File

@@ -0,0 +1,137 @@
import type { Tx } from "./db";
import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
import { jsonArrayFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format";
import { buildOrgMaps, orgMapsAbfragen } from "./org";
import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
// Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf.
//
// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der
// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion
// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war
// diese Seite damit die teuerste der Anwendung.
//
// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist
// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz
// anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts
// darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte
// durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch.
export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) {
// Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der
// Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine
// Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu
// heute gefragt lieferte om_reporting_lines() nichts, und die Akte
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
if (!employee) return null;
const asOf = orgAsOf(employee, today);
const g = await tx
.selectNoFrom((eb) => [
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// kommen aus om_reporting_lines(). Beide schränken *in* der Funktion
// ein, es wandern also neun Zeilen über die Leitung und nicht achthundert.
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"),
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"),
jsonArrayFrom(
eb
.selectFrom("employee_history")
.select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("event_date", "desc")
.orderBy("created_at", "desc")
).as("history"),
jsonArrayFrom(
eb
.selectFrom("employee_dependents")
.select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("created_at")
).as("dependents"),
jsonArrayFrom(
eb
.selectFrom("employee_notes")
.select([
"id",
"employee_id",
"author_user_id",
"author_name",
"category",
"note_text",
"due_date",
"done",
"done_by",
])
.select((x) => [
zeitstempel(x.ref("created_at")).as("created_at"),
zeitstempel(x.ref("done_at")).as("done_at"),
])
.where("employee_id", "=", id)
.orderBy("created_at", "desc")
).as("notes"),
...orgMapsAbfragen(eb),
jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"),
// Die Kontierungen der Planstellen dieser Person. Welche gilt, hängt am
// Stichtag — und der ergibt sich erst aus Eintritt und Austritt, steht
// hier also schon fest.
jsonArrayFrom(
kontierungenAbfrage(eb).where("z.position_id", "in", (e) =>
e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id)
)
).as("costRows"),
// Die offenen Planstellen bleiben bei heute: sie gehören zur
// Organisation, nicht zu dieser Person.
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
const line = g.ownLines[0] ?? null;
const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null;
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte.
const relatedIds = Array.from(
new Set(
[line?.acting_manager_id, line?.formal_manager_id, ...g.reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
);
const [relatedRows, openPositions] = await Promise.all([
relatedIds.length
? tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "job_title", "status"])
.where("id", "in", relatedIds)
.execute()
: Promise.resolve([]),
resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
]);
return {
employee,
line,
reports: g.reports,
history: g.history,
dependents: g.dependents,
notes: g.notes,
orgMaps,
placement,
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])),
};
}

35
lib/employee-search.ts Normal file
View File

@@ -0,0 +1,35 @@
// Wie aus einer Eingabe Suchmuster werden.
//
// Getrennt von der Seite, weil hier die Entscheidungen stecken, die man
// prüfen können muss: was als Wort zählt, was am Wortanfang treffen muss und
// was von LIKE als Text und nicht als Platzhalter gelesen wird.
//
// Die Bedeutung selbst — „trifft am Wortanfang" — liegt im SQL der Seite:
// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit
// Trennzeichen als Wortgrenze.
/** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */
export function istPersonalnummer(term: string): boolean {
return /^\d+$/.test(term.trim());
}
/**
* Je Suchwort zwei LIKE-Muster: „am Anfang" und „nach einem Leerzeichen".
* Zusammen ergeben sie „am Anfang eines Wortes".
*
* Ein Wort als Teilzeichenkette zu suchen wäre einfacher und war die erste
* Fassung — bei „Winkler H" traf das „H" dann auf Thomas, Katharina und
* CNC-Dreher:in, also auf alle. Je kürzer die Eingabe, desto unbrauchbarer
* das Ergebnis, und ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe.
*/
export function suchMuster(term: string): string[][] {
return term
.trim()
.split(/\s+/)
.filter(Boolean)
.map((wort) => {
// Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text.
const klein = wort.toLowerCase().replace(/[\\%_]/g, (z) => `\\${z}`);
return [`${klein}%`, `% ${klein}%`];
});
}

View File

@@ -56,8 +56,27 @@ export function initials(firstName: string, lastName: string): string {
return `${a}${b}`;
}
// "Dr. Max Mustermann, MSc MBA" — prefix titles precede the name, suffix
// titles follow after a comma, both space-joined in the stored order.
// „Mustermann, Max" — der Nachname zuerst.
//
// So steht es in jeder Personalliste, und danach wird gesucht: wer jemanden
// sucht, hat den Nachnamen im Kopf. Nebenbei stimmt die Anzeige damit auch
// mit der Sortierung überein, die schon immer nach Nachnamen ging — vorher
// las sich eine alphabetische Liste, als wäre sie ungeordnet.
//
// Eine Stelle für alle: der Name wurde an gut zwanzig Orten einzeln
// zusammengesetzt, und eine Umstellung, die die Hälfte davon vergisst, ist
// schlimmer als gar keine.
export function fmtName(firstName: string, lastName: string): string {
const nach = lastName?.trim() ?? "";
const vor = firstName?.trim() ?? "";
if (!nach) return vor;
if (!vor) return nach;
return `${nach}, ${vor}`;
}
// "Mustermann, Dr. Max, MSc MBA" — vorangestellte Titel stehen beim
// Vornamen, nachgestellte am Ende. Der Nachname bleibt vorne, damit auch die
// Überschrift einer Personalakte so beginnt wie ihr Eintrag in der Liste.
export function fmtFullName(
firstName: string,
lastName: string,
@@ -66,7 +85,7 @@ export function fmtFullName(
): string {
const prefix = titlePrefix && titlePrefix.length > 0 ? `${titlePrefix.join(" ")} ` : "";
const suffix = titleSuffix && titleSuffix.length > 0 ? `, ${titleSuffix.join(" ")}` : "";
return `${prefix}${firstName} ${lastName}${suffix}`;
return `${fmtName(`${prefix}${firstName}`, lastName)}${suffix}`;
}
// Whole years between two ISO dates. Compares the "MM-DD" tails as strings,

136
lib/history.ts Normal file
View File

@@ -0,0 +1,136 @@
import type { AuditChange, HistoryEventType } from "./supabase/types";
// Welche Historieneinträge sich zurücknehmen lassen — und warum die übrigen
// nicht.
//
// Dieselbe Regel steht in der Datenbank (delete_history_entry). Das ist eine
// Doppelung, und zwar mit Absicht: die Datenbank ist die verbindliche Stelle,
// weil sie die einzige ist, an der niemand vorbeikommt. Hier steht sie
// nochmal, damit die Oberfläche einen Knopf nur dort zeigt, wo er auch
// funktioniert, und daneben schreiben kann, woran es sonst liegt. Ein Knopf,
// der erst nach dem Klick sagt „geht nicht", ist eine Falle.
//
// Läuft eine Seite der anderen davon, gewinnt die Datenbank: sie weist ab,
// und die Oberfläche zeigt ihre Begründung.
export type KorrekturUrteil = { erlaubt: true } | { erlaubt: false; grund: string };
type Eintrag = {
event_type: HistoryEventType;
event_date: string;
changes: AuditChange[] | null;
/** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */
pending_id?: string | null;
/** Für die Reihenfolge bei gleichem Datum. */
created_at?: string;
};
/** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */
const KORRIGIERBAR: HistoryEventType[] = ["Stammdatenänderung", "Vertragsänderung", "Karenz", "Rückkehr"];
/**
* Abwesenheit und Rückkehr werden anders zurückgenommen als der Rest: aus
* einer geplanten Vertragsänderung nimmt man einzelne Felder heraus, eine
* geplante Abwesenheit fällt ganz. Beides steht in delete_history_entry;
* hier zählt nur, dass für diese beiden keine Feldwerte nötig sind.
*/
const OHNE_FELDWERTE: HistoryEventType[] = ["Karenz", "Rückkehr"];
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
if (eintrag.event_type === "Eintritt") {
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
}
if (!KORRIGIERBAR.includes(eintrag.event_type)) {
return {
erlaubt: false,
grund:
"Dieser Vorgang hat Planstellen oder den Status bewegt. Zurücknehmen lässt er sich nur über den passenden " +
"Vorgang, nicht durch Löschen der Zeile.",
};
}
// Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
// Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
// Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus einem
// Eintrag, dessen Ausgangslage gelöscht ist.
if (eintrag.event_type === "Karenz" && alle.some((h) => h.event_type === "Rückkehr" && spaeter(h, eintrag))) {
return {
erlaubt: false,
grund: "Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.",
};
}
// Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten
// Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie
// liessen sich nur über Person und Datum zuordnen, und das ist nicht
// eindeutig genug, um daraufhin eine geplante Änderung abzubrechen.
if (eintrag.event_date > heute && !eintrag.pending_id) {
return {
erlaubt: false,
grund:
"Zu dieser geplanten Änderung ist kein Vorgang hinterlegt — sie stammt aus der Zeit vor dieser Verknüpfung. " +
"Zurücknehmen lässt sie sich nur, indem der Vorgang selbst abgebrochen wird.",
};
}
// Eine geplante Abwesenheit hat keine Feldwerte — sie fällt als Ganzes.
if (eintrag.event_date > heute && OHNE_FELDWERTE.includes(eintrag.event_type)) {
return { erlaubt: true };
}
if (!eintrag.changes || eintrag.changes.length === 0) {
return {
erlaubt: false,
grund: "Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.",
};
}
return { erlaubt: true };
}
/**
* Bearbeiten ist weiter gefasst als Löschen.
*
* Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste,
* und ohne ihn hätte die Person keinen. Sein **Datum** kann aber falsch
* erfasst sein, und dann hängt daran mehr als eine Zahl: die erste
* Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das alles beim Ändern;
* hier geht es nur darum, den Knopf überhaupt anzubieten.
*/
export function darfBearbeitetWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
if (eintrag.event_type === "Eintritt") return { erlaubt: true };
return darfKorrigiertWerden(eintrag, heute, alle);
}
/** Später im Sinne der Anzeige: erst das Datum, dann die Erfassungszeit. */
function spaeter(a: Eintrag, b: Eintrag): boolean {
if (a.event_date !== b.event_date) return a.event_date > b.event_date;
return (a.created_at ?? "") > (b.created_at ?? "");
}
/**
* Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht,
* weil ein späterer Eintrag dasselbe Feld angefasst hat.
*
* Dient allein der Ankündigung im Bestätigungsdialog — entschieden wird es
* in der Datenbank, an denselben Daten, im selben Augenblick.
*/
export type Vorschau = { feld: string; von: string | null; auf: string | null; bleibt: boolean };
export function loeschVorschau(
eintrag: Eintrag & { id: string; created_at: string },
alle: (Eintrag & { id: string; created_at: string })[]
): Vorschau[] {
return (eintrag.changes ?? []).map((c) => {
const spaeter = alle.some(
(h) =>
h.id !== eintrag.id &&
(h.changes ?? []).some((a) => a.feld === c.feld) &&
(h.event_date > eintrag.event_date ||
(h.event_date === eintrag.event_date && h.created_at > eintrag.created_at))
);
return { feld: c.feld, von: c.nachher, auf: c.vorher, bleibt: spaeter };
});
}

View File

@@ -68,7 +68,9 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
jobCodes: new Map(jobs.map((j) => [j.code, j.id])),
planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])),
personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])),
emails: new Set(personen.map((e) => e.email.toLowerCase())),
// Ohne Adresse gibt es nichts zu vergleichen — seit sie freiwillig ist,
// kann sie fehlen.
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
};
}
@@ -226,8 +228,19 @@ export async function laden(
source: (txt(w.source) ?? "Extern") as never,
is_betriebsrat: bool(w.is_betriebsrat, false),
has_dienstwagen: bool(w.has_dienstwagen, false),
// Ohne Dienstwagen zwingend null, mit Dienstwagen zwingend gesetzt —
// so verlangt es chk_dienstwagen_art. Fehlt die Angabe in der Datei,
// gilt Verbrenner, wie im übernommenen Bestand.
dienstwagen_art: bool(w.has_dienstwagen, false) ? (txt(w.dienstwagen_art) ?? "Verbrenner") : null,
emergency_contact_name: txt(w.emergency_contact_name),
emergency_contact_phone: txt(w.emergency_contact_phone),
emergency_contact_relation: txt(w.emergency_contact_relation),
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
is_c_level: bool(w.is_c_level, false),
has_kuendigungsschutz: bool(w.has_kuendigungsschutz, false),
// Ohne Schutz kein Enddatum — chk_kuendigungsschutz_bis weist es sonst
// ab, und die ganze Datei scheiterte an einer einzelnen Zeile.
kuendigungsschutz_bis: bool(w.has_kuendigungsschutz, false) ? (txt(w.kuendigungsschutz_bis) ?? null) : null,
entry_date: eintritt,
exit_date: austritt,
exit_reason: txt(w.exit_reason),
@@ -244,18 +257,15 @@ export async function laden(
),
};
// Von Hand geschrieben statt über den Abfragebauer, wegen genau eines
// Wortes: OVERRIDING SYSTEM VALUE.
//
// personnel_number ist GENERATED ALWAYS AS IDENTITY — die Datenbank
// vergibt sie und weist einen eigenen Wert sonst ab. Für eine Übernahme
// aus einem Altsystem ist das die falsche Richtung: die Nummer steht auf
// Lohnzetteln, in Akten und auf Ausweisen. Ein Import, der sie neu
// würfelt, ist keine Übernahme.
// Weiterhin von Hand geschrieben, aber ohne OVERRIDING SYSTEM VALUE:
// personnel_number ist seit 20260811100000 keine Identitätsspalte mehr,
// sondern eine gewöhnliche Pflichtangabe — sie muss mit Loga und
// Interflex übereinstimmen und wird deshalb überall eingegeben, nicht
// vergeben. Für eine Identitätsspalte war das Schlüsselwort nötig; für
// eine gewöhnliche wäre es ein Fehler.
const spalten = Object.keys(werte);
const r = await sql<{ id: string; personnel_number: number }>`
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
overriding system value
values (${sql.join(Object.values(werte).map((v) => sql.val(v)))})
returning id, personnel_number
`.execute(tx);
@@ -276,18 +286,9 @@ export async function laden(
}
bericht.Personen = personenZeilen.length;
// Den Zähler nachziehen. Ohne das vergibt die Datenbank für die nächste
// Neueinstellung eine Nummer, die der Import bereits verbraucht hat — und
// der eindeutige Index weist sie ab. Der Fehler träte erst Wochen später
// auf, beim ersten Eintritt nach der Übernahme.
if (personenZeilen.length > 0) {
await sql`
select setval(
pg_get_serial_sequence('employees', 'personnel_number'),
(select max(personnel_number) from employees)
)
`.execute(tx);
}
// Kein Fortschreiben eines Zählers mehr: es gibt keinen. Die Nummer wird
// bei jeder Einstellung eingegeben, und die Eindeutigkeit sichert der
// Index — beim Import wie im Assistenten.
await einfuegen(tx, "position_assignments", besetzungen);

View File

@@ -224,8 +224,15 @@ export const BLATT_PERSONEN: BlattSchema = {
hinweis: "Leer = Österreich.",
beispiel: "Österreich",
},
{ name: "E-Mail", ziel: "email", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "s.aigner@example.at" },
{ name: "Telefon", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{
name: "Private E-Mail",
ziel: "email",
pflicht: false,
typ: { art: "text" },
hinweis: "Freiwillig. Eindeutig, wenn angegeben.",
beispiel: "s.aigner@example.at",
},
{ name: "Private Telefonnummer", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
@@ -350,8 +357,56 @@ export const BLATT_PERSONEN: BlattSchema = {
},
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{
name: "Antriebsart",
ziel: "dienstwagen_art",
pflicht: false,
typ: { art: "auswahl", werte: ["Verbrenner", "Elektro"] },
hinweis: "Pflicht, wenn ein Dienstwagen eingetragen ist; sonst leer lassen.",
beispiel: "",
},
{
name: "Notfallkontakt",
ziel: "emergency_contact_name",
pflicht: false,
typ: { art: "text" },
hinweis: "Name. Nur zusammen mit der Telefonnummer.",
beispiel: "",
},
{
name: "Notfallkontakt Telefon",
ziel: "emergency_contact_phone",
pflicht: false,
typ: { art: "text" },
hinweis: "Nur zusammen mit dem Namen.",
beispiel: "",
},
{
name: "Notfallkontakt Verhältnis",
ziel: "emergency_contact_relation",
pflicht: false,
typ: { art: "text" },
hinweis: "Zum Beispiel Gattin, Bruder, Freundin.",
beispiel: "",
},
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{
name: "Besonderer Kündigungsschutz",
ziel: "has_kuendigungsschutz",
pflicht: false,
typ: { art: "janein" },
hinweis: "Leer = nein.",
beispiel: "nein",
},
{
name: "Kündigungsschutz bis",
ziel: "kuendigungsschutz_bis",
pflicht: false,
typ: { art: "datum" },
hinweis: "Nur mit gesetztem Schutz; freiwillig.",
beispiel: "",
},
{
name: "Abwesenheit ab",
ziel: "karenz_start_date",

View File

@@ -277,9 +277,9 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
const email = s(w.email)?.toLowerCase();
if (email) {
if (emails.has(email)) melde("Personen", z.zeile, "E-Mail", "Kommt bereits vor.", email);
if (emails.has(email)) melde("Personen", z.zeile, "Private E-Mail", "Kommt bereits vor.", email);
emails.add(email);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "E-Mail", "Sieht nicht wie eine Adresse aus.", email);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "Private E-Mail", "Sieht nicht wie eine Adresse aus.", email);
}
const geburt = s(w.birth_date);

View File

@@ -1,10 +1,21 @@
import type { Tx } from "./db";
import { jsonArrayFrom, zeitstempel } from "./db/json";
import { fmtName } from "./format";
import type { OrgEb } from "./org";
import type { Database } from "./supabase/types";
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
employeeName: string;
};
/** Was die Teilabfrage liefert: die Notiz plus den Namen aus dem Join. */
export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Row"], "created_at" | "done_at"> & {
created_at: string;
done_at: string | null;
first_name: string | null;
last_name: string | null;
};
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
@@ -12,21 +23,51 @@ export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
// direkten Zugang ist es schlicht ein Join.
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
const rows = await tx
/**
* Die offenen Notizen als *Teilabfrage* — zum Einhängen in die eine Abfrage,
* die eine Seite ohnehin stellt (lib/db/json.ts).
*
* Die beiden Zeitstempel gehen durch zeitstempel(): innerhalb von JSON
* formatiert Postgres sie anders als der Treiber es sonst täte, und der
* Unterschied fällt erst beim Vergleichen auf.
*/
export function offeneNotizenAbfrage(eb: OrgEb) {
return eb
.selectFrom("employee_notes as n")
.leftJoin("employees as e", "e.id", "n.employee_id")
.selectAll("n")
.select(["e.first_name", "e.last_name"])
.select([
"n.id",
"n.employee_id",
"n.author_user_id",
"n.author_name",
"n.category",
"n.note_text",
"n.due_date",
"n.done",
"n.done_by",
"e.first_name",
"e.last_name",
])
.select((x) => [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")])
.where("n.done", "=", false)
.orderBy("n.created_at", "desc")
.execute();
.orderBy("n.created_at", "desc");
}
/** Der reine Teil: aus den Zeilen die Notizen mit lesbarem Namen. */
export function baueOffeneNotizen(rows: NotizZeile[]): OpenNote[] {
return rows.map((row) => {
const { first_name, last_name, ...note } = row;
return {
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
employeeName: first_name && last_name ? fmtName(first_name, last_name) : "Unbekannt",
};
});
}
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
const { notes } = await tx
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes")])
.executeTakeFirstOrThrow();
return baueOffeneNotizen(notes as NotizZeile[]);
}

View File

@@ -1,6 +1,15 @@
import type { ExpressionBuilder } from "kysely";
import type { Tx } from "./db";
import { jsonArrayFrom } from "./db/json";
import type { Schema } from "./db/schema";
import type { Database } from "./supabase/types";
/**
* Der Ausdrucksbauer einer Abfrage ohne eigene Tabelle (selectNoFrom) — das
* ist der Ort, an dem Teilabfragen zusammengehängt werden.
*/
export type OrgEb = ExpressionBuilder<Schema, never>;
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte
@@ -16,7 +25,7 @@ export type OrgUnit = {
unit_type: OrgUnitType;
};
type Location = Database["public"]["Tables"]["locations"]["Row"];
export type Location = Database["public"]["Tables"]["locations"]["Row"];
export type OrgMaps = {
units: Map<string, OrgUnit>;
@@ -31,15 +40,30 @@ export type OrgMaps = {
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
/**
* Einheiten und Standorte als *Teilabfragen* — zum Einhängen in die eine
* Abfrage, die eine Seite ohnehin stellt.
*
* Fast jede Seite braucht den Baum. Als eigene Abfrage wäre das je Seitenauf-
* bau eine zusätzliche Rundreise für 65 Zeilen, die sich selten ändern; als
* Teilabfrage kostet sie nichts (lib/db/json.ts).
*/
export function orgMapsAbfragen(eb: OrgEb) {
return [
jsonArrayFrom(
eb.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number")
).as("units"),
jsonArrayFrom(eb.selectFrom("locations").selectAll().orderBy("name")).as("locations"),
] as const;
}
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
const [units, locations] = await Promise.all([
tx
.selectFrom("org_units")
.select(["id", "org_number", "name", "parent_id", "unit_type"])
.orderBy("org_number")
.execute(),
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
]);
// Beides in einer Rundreise. Ein Promise.all wäre hier keine
// Gleichzeitigkeit gewesen, sondern eine Schlange — der Grund steht in
// lib/db/json.ts.
const { units, locations } = await tx
.selectNoFrom((eb) => [...orgMapsAbfragen(eb)])
.executeTakeFirstOrThrow();
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
}

152
lib/orgchart-print.ts Normal file
View File

@@ -0,0 +1,152 @@
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
import { fmtName } from "@/lib/format";
// Das Modell für den Druck.
//
// Der Bildschirm zeigt das Organigramm als Fläche, in die man hineinzoomt.
// Papier kann das nicht: es hat eine feste Grösse, und was darauf nicht
// lesbar ist, ist verloren. Ein Ausdruck der Leinwand — 850 Personen auf eine
// Seite skaliert — ergibt Kästchen von zwei Millimetern.
//
// Deshalb wird für den Druck **umgebaut statt verkleinert**: eine Übersicht
// über die obersten Ebenen, und danach ein Bereich je Seite als eigener,
// vollständiger Baum. Jede Seite ist für sich verständlich und trägt ihre
// Überschrift — auch wenn jemand nur Blatt sieben aus dem Drucker nimmt.
//
// Der Baum bleibt hier ein Baum: `children` und `totalPeople` sind das, woraus
// die Auswahl („welche Bereiche, wie tief") und die gezeichnete Hierarchie
// gleichermassen entstehen.
export type PrintPerson = {
id: string;
/** Getrennt mitgeführt, weil danach sortiert wird — siehe nachName. */
lastName: string;
name: string;
jobTitle: string;
personnelNumber: number;
isChief: boolean;
absent: boolean;
absenceType: string | null;
};
export type PrintUnit = {
id: string;
name: string;
unitType: OrgUnitNode["unit_type"];
orgNumber: string;
/** Die Leitung dieser Einheit, falls besetzt. */
chief: PrintPerson | null;
/** Alle übrigen Personen, die unmittelbar in dieser Einheit sitzen. */
members: PrintPerson[];
/** Unbesetzte Planstellen dieser Einheit. */
vacancies: { positionNumber: string; jobTitle: string; isChief: boolean }[];
children: PrintUnit[];
/** Personen in dieser Einheit und allem darunter — für die Übersicht. */
totalPeople: number;
};
export type PrintModel = {
root: PrintUnit | null;
/** Je Bereich eine Seite. Bei nur einer Ebene bleibt es bei der Übersicht. */
divisions: PrintUnit[];
totals: { people: number; vacancies: number; units: number };
};
function personFrom(e: OrgEmployee): PrintPerson {
return {
id: e.id,
lastName: e.last_name,
name: fmtName(e.first_name, e.last_name),
jobTitle: e.job_title,
personnelNumber: e.personnel_number,
isChief: e.is_chief,
absent: e.absent,
absenceType: e.absence_type,
};
}
/**
* Nachname, dann Vorname — wie in jeder Liste, die jemand durchsucht.
*
* Der Nachname kommt aus dem eigenen Feld, nicht aus dem zusammengesetzten
* Namen. Vorher wurde das letzte Wort genommen, was bei „Hannah Winkler"
* zufällig stimmte; seit die Anzeige „Winkler, Hannah" lautet, wäre das
* letzte Wort der Vorname, und die Liste stünde nach Vornamen sortiert da.
*/
function nachName(a: PrintPerson, b: PrintPerson): number {
return a.lastName.localeCompare(b.lastName, "de") || a.name.localeCompare(b.name, "de");
}
export function buildPrintModel(
units: OrgUnitNode[],
employees: OrgEmployee[],
vacancies: OrgVacancy[]
): PrintModel {
const byUnit = new Map<string, PrintUnit>();
for (const u of units) {
byUnit.set(u.id, {
id: u.id,
name: u.name,
unitType: u.unit_type,
orgNumber: u.org_number,
chief: null,
members: [],
vacancies: [],
children: [],
totalPeople: 0,
});
}
for (const e of employees) {
const unit = byUnit.get(e.org_unit_id);
if (!unit) continue;
const p = personFrom(e);
// Die Leitung steht oben und nicht in der Namensliste — sie ist die
// Antwort auf „wer führt das hier", und die soll man nicht suchen.
if (p.isChief && !unit.chief) unit.chief = p;
else unit.members.push(p);
}
for (const v of vacancies) {
const unit = byUnit.get(v.org_unit_id);
if (!unit) continue;
unit.vacancies.push({ positionNumber: v.position_number, jobTitle: v.job_title, isChief: v.is_chief });
}
// Bäume verknüpfen, in der Reihenfolge der Orgnummern — dieselbe wie
// überall sonst in der Anwendung.
const sortiert = [...units].sort((a, b) => a.org_number.localeCompare(b.org_number, "de"));
let root: PrintUnit | null = null;
for (const u of sortiert) {
const node = byUnit.get(u.id)!;
if (u.parent_id && byUnit.has(u.parent_id)) byUnit.get(u.parent_id)!.children.push(node);
else if (u.unit_type === "Gesellschaft") root = node;
}
for (const unit of byUnit.values()) {
unit.members.sort(nachName);
unit.vacancies.sort((a, b) => a.jobTitle.localeCompare(b.jobTitle, "de"));
}
// Summen von unten nach oben. Rekursiv über höchstens vier Ebenen — die
// Tiefe ist durch das Modell begrenzt (Gesellschaft/Bereich/Abteilung/Team).
function zaehle(unit: PrintUnit): number {
const eigene = (unit.chief ? 1 : 0) + unit.members.length;
unit.totalPeople = eigene + unit.children.reduce((s, c) => s + zaehle(c), 0);
return unit.totalPeople;
}
if (root) zaehle(root);
else for (const u of byUnit.values()) if (!units.find((x) => x.id === u.id)?.parent_id) zaehle(u);
const divisions = root ? root.children : [...byUnit.values()].filter((u) => u.unitType === "Bereich");
return {
root,
divisions,
totals: {
people: employees.length,
vacancies: vacancies.length,
units: units.length,
},
};
}

View File

@@ -1,4 +1,34 @@
import { sql, type Tx } from "./db";
import { addDaysIso } from "./format";
import type { OrgEb } from "./org";
/**
* Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird.
*
* „Heute" ist für zwei Gruppen die falsche Frage. Wer am 01.09. anfängt, hat
* heute keine laufende Besetzung — om_reporting_lines() liefert dann gar
* keine Zeile, und die Akte behauptet „Keine Führungskraft", obwohl das Team
* eine hat. Wer ausgetreten ist, ebenso.
*
* Die Stammdaten zeigten die Planstelle trotzdem an, weil pickPlacements()
* ersatzweise auf die nächstbeste Zuordnung zurückfällt. Zwei Vorstellungen
* davon, wo jemand sitzt — eine nachsichtige und eine strenge — und in der
* Akte standen sie nebeneinander.
*
* Deshalb: der Stichtag wird in das Beschäftigungsverhältnis hineingezogen.
* Für künftige Eintritte auf den ersten Tag, für Ausgetretene auf den
* letzten. Sonst bleibt es heute.
*/
export function orgAsOf(
employee: { entry_date: string; exit_date: string | null },
today: string
): string {
if (employee.entry_date > today) return employee.entry_date;
// exit_date ist ausschliessend wie überall im Modell: der letzte Arbeitstag
// ist der Tag davor.
if (employee.exit_date && employee.exit_date <= today) return addDaysIso(employee.exit_date, -1);
return today;
}
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
@@ -68,6 +98,34 @@ export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement
return byEmployee;
}
/**
* Die Besetzungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
* eine Seite ohnehin stellt (lib/db/json.ts).
*
* Ein Join statt einer eingebetteten Ressource. Und ohne die
* 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg, das
* es dafür brauchte.
*/
export function besetzungenAbfrage(eb: OrgEb, employeeIds?: string[]) {
const q = eb
.selectFrom("position_assignments as pa")
.innerJoin("om_positions as p", "p.id", "pa.position_id")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select([
"pa.employee_id",
"pa.valid_from",
"pa.valid_to",
"p.id as position_id",
"p.position_number",
"p.org_unit_id",
"p.is_chief",
"j.title as job_title",
])
.orderBy("pa.employee_id");
return employeeIds ? q.where("pa.employee_id", "in", employeeIds) : q;
}
export async function loadPlacements(
tx: Tx,
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
@@ -119,6 +177,25 @@ export type ReportingLine = {
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
* achthundert.
*/
/**
* Die Berichtslinien als *Teilabfrage* — zum Einhängen in die eine Abfrage,
* die eine Seite ohnehin stellt (lib/db/json.ts).
*
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher.
*/
export function berichtslinienAbfrage(
eb: OrgEb,
asOf: string,
filter?: { employeeId?: string; actingManagerId?: string }
) {
let q = eb
.selectFrom(sql<ReportingLine>`om_reporting_lines(${asOf}::date)`.as("l"))
.select(["l.employee_id", "l.position_id", "l.org_unit_id", "l.is_chief", "l.formal_manager_id", "l.acting_manager_id"]);
if (filter?.employeeId) q = q.where("l.employee_id", "=", filter.employeeId);
if (filter?.actingManagerId) q = q.where("l.acting_manager_id", "=", filter.actingManagerId);
return q;
}
export async function loadReportingLines(
tx: Tx,
asOf: string,

View File

@@ -1,6 +1,8 @@
import { kontierungAm, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
import type { Tx } from "./db";
import { todayIso } from "./format";
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
import { jsonArrayFrom } from "./db/json";
import { fmtName, todayIso } from "./format";
import { breadcrumbLabel, buildOrgMaps, orgMapsAbfragen, type OrgEb, type OrgMaps } from "./org";
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
@@ -14,11 +16,30 @@ export type OpenPositionResolved = {
org_unit_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
/** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */
org_unit_name: string;
/**
* Ob die Planstelle heute schon gilt.
*
* Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" —
* sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides
* gleich zu benennen wäre falsch.
*/
future: boolean;
/** Wer die Stelle nach der Berichtslinie führen wird. */
managerName: string | null;
orgLabel: string;
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
vacantSince: string;
/**
* Wessen Budget die Stelle belastet.
*
* Der Grund, warum die Kostenstelle an der Planstelle hängt und nicht an der
* Person: hier gibt es keine Person, und trotzdem — gerade deshalb — ist die
* Frage offen, was diese Stelle kostet und wer sie bezahlt.
*/
kostenstelle: { code: string; name: string } | null;
};
/**
@@ -31,90 +52,116 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str
return maps.units.get(orgUnitId)?.parent_id ?? null;
}
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
const asOf = todayIso();
export type OffeneStelle = {
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
title: string;
};
const [orgMaps, open] = await Promise.all([
loadOrgMaps(tx),
// Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die
// erst beginnt.**
//
// Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am
// 24.09. anfängt, belegt die Planstelle heute schon; sie steht nur noch
// nicht besetzt da. Die frühere Fassung fragte „sitzt heute jemand
// darauf?" und listete solche Stellen als offen — mit „seit 2 Tagen
// unbesetzt" daneben. Aus dieser Liste speist sich auch die Auswahl im
// Einstellungsassistenten, also lud sie dazu ein, dieselbe Stelle ein
// zweites Mal zu besetzen. Aufgefallen wäre das erst am Teilindex der
// Datenbank, nach dem Gespräch mit der zweiten Person.
//
// Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle
// dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to.
//
/**
* Die unbesetzten Planstellen als *Teilabfrage*.
*
* Als eigene Funktion, damit eine Seite sie zusammen mit ihren übrigen
* Lesevorgängen in einer Rundreise holen kann statt in einer eigenen — siehe
* lib/db/json.ts. Wer nichts weiter zu holen hat, nimmt loadOpenPositions().
*
* Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die erst
* beginnt.**
*
* Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am 24.09.
* anfängt, belegt die Planstelle heute schon; sie steht nur noch nicht besetzt
* da. Die frühere Fassung fragte „sitzt heute jemand darauf?" und listete
* solche Stellen als offen — mit „seit 2 Tagen unbesetzt" daneben. Aus dieser
* Liste speist sich auch die Auswahl im Einstellungsassistenten, also lud sie
* dazu ein, dieselbe Stelle ein zweites Mal zu besetzen. Aufgefallen wäre das
* erst am Teilindex der Datenbank, nach dem Gespräch mit der zweiten Person.
*
* Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle
* dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to.
*
* Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese Ansicht
* existiert: eine Stelle, die zum Quartalswechsel entsteht, muss vorher
* sichtbar und planbar sein.
*/
export function offeneStellenAbfrage(eb: OrgEb, asOf: string) {
return eb
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
.where((e) => e.or([e("p.valid_to", "is", null), e("p.valid_to", ">", asOf)]))
// Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen
// im Speicher.
tx
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "j.title"])
.where("p.valid_from", "<=", asOf)
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("position_assignments as a")
.select("a.id")
.whereRef("a.position_id", "=", "p.id")
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
)
.where((e) =>
e.not(
e.exists(
e
.selectFrom("position_assignments as a")
.select("a.id")
.whereRef("a.position_id", "=", "p.id")
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
)
)
.orderBy("p.position_number")
.execute(),
]);
)
.orderBy("p.position_number");
}
/**
* Der zweite Teil: die beiden Nachschläge, die erst gestellt werden können,
* wenn feststeht, welche Stellen offen sind — seit wann sie leer stehen, und
* wer sie führen würde. Beide in einer Rundreise.
*/
export async function resolveOpenPositions(
tx: Tx,
orgMaps: OrgMaps,
open: OffeneStelle[],
asOf: string = todayIso()
): Promise<OpenPositionResolved[]> {
if (open.length === 0) return [];
const positionIds = open.map((p) => p.id);
const chiefUnitIds = Array.from(
new Set(
open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id))
)
);
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
// würde.
const [ended, chiefs] = await Promise.all([
tx
.selectFrom("position_assignments")
.select(["position_id", "valid_to"])
.where("position_id", "in", positionIds)
.where("valid_to", "is not", null)
.execute(),
(async () => {
const chiefUnitIds = Array.from(
new Set(
open
.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief))
.filter((id): id is string => Boolean(id))
)
);
if (chiefUnitIds.length === 0) return [];
return tx
.selectFrom("om_positions as p")
.innerJoin("position_assignments as a", "a.position_id", "p.id")
.innerJoin("employees as e", "e.id", "a.employee_id")
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
.where("p.is_chief", "=", true)
.where("p.valid_to", "is", null)
.where("a.valid_to", "is", null)
.where("p.org_unit_id", "in", chiefUnitIds)
.execute();
})(),
]);
const { ended, chiefs, costRows } = await tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("position_assignments")
.select(["position_id", "valid_to"])
.where("position_id", "in", positionIds)
.where("valid_to", "is not", null)
).as("ended"),
jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("costRows"),
jsonArrayFrom(
eb
.selectFrom("om_positions as p")
.innerJoin("position_assignments as a", "a.position_id", "p.id")
.innerJoin("employees as e", "e.id", "a.employee_id")
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
.where("p.is_chief", "=", true)
.where("p.valid_to", "is", null)
.where("a.valid_to", "is", null)
// Ohne Einheiten darf hier keine Liste stehen: `in ()` ist ein
// Syntaxfehler, und `in ('')` bricht an der uuid-Umwandlung ab
// statt nichts zu liefern. Dann also eine Bedingung, die nie zutrifft.
.where((e) => (chiefUnitIds.length > 0 ? e("p.org_unit_id", "in", chiefUnitIds) : e.lit(false)))
).as("chiefs"),
])
.executeTakeFirstOrThrow();
const lastEndByPosition = new Map<string, string>();
for (const e of ended) {
const prev = lastEndByPosition.get(e.position_id);
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
}
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, fmtName(c.first_name, c.last_name)]));
return open.map((p) => {
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
@@ -125,9 +172,36 @@ export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]>
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
valid_to: p.valid_to,
org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "",
future: p.valid_from > asOf,
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
// Nicht zum heutigen Tag, sondern zu dem, an dem die Stelle besteht:
// eine künftige Planstelle wird erst mit ihrem Beginn kontiert, hätte
// heute also keine Kostenstelle — obwohl gerade sie geplant wird.
kostenstelle: kontierungAm(costRows as KontierungsZeile[], p.id, p.valid_from > asOf ? p.valid_from : asOf),
};
});
}
/**
* Der bequeme Weg: Organisationsbaum und offene Stellen in einer Rundreise,
* die beiden Nachschläge in einer zweiten.
*
* Zwei sind das Minimum — was in der zweiten steht, hängt vom Ergebnis der
* ersten ab.
*/
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
const asOf = todayIso();
const { units, locations, open } = await tx
.selectNoFrom((eb) => [
...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
])
.executeTakeFirstOrThrow();
return resolveOpenPositions(tx, buildOrgMaps(units as never, locations as never), open as OffeneStelle[], asOf);
}

181
lib/report-criteria.ts Normal file
View File

@@ -0,0 +1,181 @@
import { ABSENCE_TYPES } from "./absence";
import { parseIsoDateParam } from "./reports";
import type { Weekday } from "./supabase/types";
// Ein Verzeichnis aller Auswahlkriterien — für die Oberfläche, die Abfrage
// und den Export dasselbe.
//
// Ein Export, der weniger filtern kann als der Bericht daneben, ist der
// Grund, warum Leute Daten nach Excel kippen und dort weiterarbeiten: was
// die Anwendung nicht hergibt, wird eben von Hand nachgebaut, und ab da
// stimmt es mit nichts mehr überein. Deshalb steht hier jedes Kriterium
// einmal, mit seinen erlaubten Werten, und alle drei Seiten lesen aus
// derselben Liste:
//
// * die Oberfläche baut ihre Felder daraus,
// * `parseCriteria` prüft die Adresszeile dagegen,
// * `applyCriteria` (serverseitig) macht Bedingungen daraus.
//
// Ein neues Kriterium ist damit ein Eintrag in dieser Datei und sonst
// nichts — und es kann nicht passieren, dass es im Bericht wirkt, im Export
// aber stillschweigend ignoriert wird.
//
// Die Prüfung ist nicht bloss Ordnungsliebe: die Werte landen in
// SQL-Vergleichen und im Dateinamen des Downloads, also in einem
// Content-Disposition-Header. Was nicht in der Liste steht, kommt nicht
// durch.
export type JaNein = "ja" | "nein";
/** Ein Kriterium mit fester Werteliste. */
export type AuswahlKriterium = {
key: string;
label: string;
/** Beschriftung für „keine Einschränkung". */
alle: string;
optionen: readonly { wert: string; label: string }[];
};
function werte(...w: string[]): { wert: string; label: string }[] {
return w.map((x) => ({ wert: x, label: x }));
}
export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
{ key: "employment", label: "Beschäftigungsart", alle: "Alle Beschäftigungsarten", optionen: werte("Vollzeit", "Teilzeit") },
{ key: "contract", label: "Vertragsart", alle: "Alle Vertragsarten", optionen: werte("unbefristet", "befristet") },
{ key: "worker", label: "Angestellte:r / Arbeiter:in", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in") },
{ key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") },
{ key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") },
{ key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") },
{
key: "gender",
label: "Geschlecht",
alle: "Alle",
optionen: [
{ wert: "m", label: "männlich" },
{ wert: "w", label: "weiblich" },
],
},
{ key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") },
{ key: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) },
{
key: "teilzeitArt",
label: "Teilzeitvariante",
alle: "Alle Teilzeitvarianten",
optionen: werte("Bildungsteilzeit", "Elternteilzeit", "Pflegeteilzeit", "Wiedereingliederungsteilzeit"),
},
{ key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") },
] as const;
/** Kriterien über ein Ja/Nein-Feld. */
export type JaNeinKriterium = { key: string; label: string };
export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
{ key: "dienstwagen", label: "Dienstwagen" },
{ key: "betriebsrat", label: "Betriebsrat" },
{ key: "lateral", label: "Laterale Führung" },
{ key: "clevel", label: "C-Level" },
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
{ key: "teilzeit", label: "In einer Teilzeitvariante" },
{ key: "aufenthaltstitel", label: "Aufenthaltstitel" },
{ key: "dependents", label: "Angehörige erfasst" },
] as const;
/** Zeiträume: je ein Parameter `<key>From` und `<key>To`. */
export type ZeitraumKriterium = { key: string; label: string };
export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
{ key: "entry", label: "Eintritt" },
{ key: "exit", label: "Austritt" },
{ key: "birth", label: "Geburtsdatum" },
{ key: "schutz", label: "Kündigungsschutz bis" },
{ key: "teilzeitEnde", label: "Teilzeit endet" },
{ key: "titelEnde", label: "Aufenthaltstitel läuft ab" },
] as const;
export type Criteria = {
/** Werteliste je Kriteriumsschlüssel aus AUSWAHL_KRITERIEN. */
auswahl: Record<string, string>;
/** Ja/Nein je Schlüssel aus JANEIN_KRITERIEN. */
jaNein: Record<string, JaNein>;
/** ISO-Daten je Schlüssel aus ZEITRAUM_KRITERIEN, jeweils von/bis. */
zeitraum: Record<string, { von?: string; bis?: string }>;
/** Wochenstunden von/bis. */
stundenVon?: number;
stundenBis?: number;
};
export const LEERE_CRITERIA: Criteria = { auswahl: {}, jaNein: {}, zeitraum: {} };
function parseZahl(value: string | null | undefined): number | undefined {
if (!value) return undefined;
const n = Number(value.replace(",", "."));
// Negative Stunden oder eine Woche mit 200 Stunden sind keine Eingabe,
// sondern ein Tippfehler oder ein Versuch.
return Number.isFinite(n) && n >= 0 && n <= 168 ? n : undefined;
}
/**
* Liest die Kriterien aus der Adresszeile und verwirft alles, was nicht in
* den Listen oben steht.
*/
export function parseCriteria(get: (key: string) => string | null | undefined): Criteria {
const auswahl: Record<string, string> = {};
for (const k of AUSWAHL_KRITERIEN) {
const roh = get(k.key);
if (roh && k.optionen.some((o) => o.wert === roh)) auswahl[k.key] = roh;
}
const jaNein: Record<string, JaNein> = {};
for (const k of JANEIN_KRITERIEN) {
const roh = get(k.key);
if (roh === "ja" || roh === "nein") jaNein[k.key] = roh;
}
const zeitraum: Record<string, { von?: string; bis?: string }> = {};
for (const k of ZEITRAUM_KRITERIEN) {
const von = parseIsoDateParam(get(`${k.key}From`));
const bis = parseIsoDateParam(get(`${k.key}To`));
if (von || bis) zeitraum[k.key] = { von, bis };
}
return { auswahl, jaNein, zeitraum, stundenVon: parseZahl(get("hoursFrom")), stundenBis: parseZahl(get("hoursTo")) };
}
/** Wie viele Kriterien gesetzt sind — für die Anzeige „(3)" am Aufklapper. */
export function anzahlKriterien(c: Criteria): number {
let n = Object.keys(c.auswahl).length + Object.keys(c.jaNein).length;
for (const z of Object.values(c.zeitraum)) n += (z.von ? 1 : 0) + (z.bis ? 1 : 0);
if (c.stundenVon !== undefined) n += 1;
if (c.stundenBis !== undefined) n += 1;
return n;
}
/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */
export function kriterienParameter(): string[] {
return [
...AUSWAHL_KRITERIEN.map((k) => k.key),
...JANEIN_KRITERIEN.map((k) => k.key),
...ZEITRAUM_KRITERIEN.flatMap((k) => [`${k.key}From`, `${k.key}To`]),
"hoursFrom",
"hoursTo",
];
}
/**
* Die zwei Kriterien, die keine Spalte sind: der Wochentag steckt in einem
* Array, die Angehörigen in einer anderen Tabelle. Beide werden an den
* geladenen Zeilen geprüft, statt den Enum-Vergleich bzw. einen Join in die
* Abfrage zu zwingen.
*/
export function passtImSpeicher(
zeile: { work_days: Weekday[] | string[]; dependentsCount: number },
c: Criteria
): boolean {
const tag = c.auswahl.weekday;
if (tag && !zeile.work_days.includes(tag as Weekday)) return false;
const angehoerige = c.jaNein.dependents;
if (angehoerige === "ja" && zeile.dependentsCount === 0) return false;
if (angehoerige === "nein" && zeile.dependentsCount > 0) return false;
return true;
}

View File

@@ -1,8 +1,31 @@
import type { SelectQueryBuilder } from "kysely";
import type { Schema } from "./db/schema";
import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
import type { EmploymentType, HistoryEventType } from "./supabase/types";
import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
import {
deriveStatusAsOf,
EVENT_DATE_OPEN,
parseStatuses,
todayIso,
type OrgLookups,
type ReportEmployee,
type ReportEvent,
type UnitOption,
} from "./reports";
import type {
CollectiveAgreement,
ContractType,
DienstwagenArt,
EmploymentType,
GenderType,
HistoryEventType,
PaygradeType,
SourceType,
TeilzeitArt,
WorkerType,
} from "./supabase/types";
// Shared by the Berichte page and /api/export/* so they can never drift on
// what "the current view" means — same filters, same stichtag/event-window
@@ -12,9 +35,72 @@ export type ReportFilters = {
division?: string;
location?: string;
status?: string;
employment?: string;
/** Alles Weitere — siehe lib/report-criteria.ts. */
criteria?: Criteria;
};
/**
* Macht aus den Kriterien Bedingungen auf `employees`.
*
* Bewusst Zeile für Zeile statt über eine Tabelle Schlüssel→Spalte: so prüft
* der Compiler jeden Wert gegen den Spaltentyp. Die Umwandlungen sind keine
* blinden Zusicherungen — `parseCriteria` hat den Wert vorher gegen dieselbe
* Werteliste geprüft, aus der auch der Aufzählungstyp besteht.
*
* Nicht hier: Wochentag und Angehörige (siehe passtImSpeicher), Einheit
* (Teilbaum) und Status (zum Stichtag abgeleitet) — die drei kann keine
* Spaltenbedingung ausdrücken.
*/
export function applyCriteria<O>(
q: SelectQueryBuilder<Schema, "employees", O>,
c: Criteria
): SelectQueryBuilder<Schema, "employees", O> {
const a = c.auswahl;
if (a.employment) q = q.where("employment_type", "=", a.employment as EmploymentType);
if (a.contract) q = q.where("contract_type", "=", a.contract as ContractType);
if (a.worker) q = q.where("worker_type", "=", a.worker as WorkerType);
if (a.kv) q = q.where("collective_agreement", "=", a.kv as CollectiveAgreement);
if (a.paygrade) q = q.where("paygrade", "=", a.paygrade as PaygradeType);
if (a.source) q = q.where("source", "=", a.source as SourceType);
if (a.gender) q = q.where("gender", "=", a.gender as GenderType);
if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt);
if (a.teilzeitArt) q = q.where("teilzeit_art", "=", a.teilzeitArt as TeilzeitArt);
if (a.absence) q = q.where("absence_type", "=", a.absence);
const j = c.jaNein;
if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja");
if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja");
if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja");
if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja");
if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja");
// „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche.
if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null);
if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null);
if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "ja");
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
if (c.zeitraum.entry?.von) q = q.where("entry_date", ">=", c.zeitraum.entry.von);
if (c.zeitraum.entry?.bis) q = q.where("entry_date", "<=", c.zeitraum.entry.bis);
if (c.zeitraum.exit?.von) q = q.where("exit_date", ">=", c.zeitraum.exit.von);
if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis);
if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von);
if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis);
if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von);
if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis);
if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von);
if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis);
// Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im
// nächsten Quartal aus".
if (c.zeitraum.titelEnde?.von) q = q.where("aufenthaltstitel_bis", ">=", c.zeitraum.titelEnde.von);
if (c.zeitraum.titelEnde?.bis) q = q.where("aufenthaltstitel_bis", "<=", c.zeitraum.titelEnde.bis);
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);
return q;
}
export type SnapshotFilters = ReportFilters & { asOf?: string };
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
@@ -42,7 +128,7 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na
export async function loadOrgLookups(tx: Tx): Promise<{
lookups: OrgLookups;
orgMaps: OrgMaps;
divisions: { id: string; name: string }[];
units: UnitOption[];
locations: { id: string; name: string }[];
}> {
const orgMaps = await loadOrgMaps(tx);
@@ -51,10 +137,15 @@ export async function loadOrgLookups(tx: Tx): Promise<{
return {
lookups: lookupsFromOrgMaps(orgMaps, locations),
orgMaps,
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft —
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den
// ganzen Teilbaum.
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })),
// Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung
// auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen.
// Der Filter greift immer auf den ganzen Teilbaum darunter.
units: orgMaps.unitList.map((u) => ({
id: u.id,
name: u.name,
depth: orgMaps.depthOf.get(u.id) ?? 0,
unitType: u.unit_type,
})),
locations,
};
}
@@ -83,6 +174,7 @@ const SNAPSHOT_EMPLOYEE_COLUMNS = [
"has_dienstwagen",
"is_laterale_fuehrung",
"is_c_level",
"teilzeit_art",
] as const;
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
@@ -105,8 +197,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
function snapshotQuery() {
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
return q;
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
}
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
@@ -121,6 +212,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
// Bereich selbst nur die Bereichsleitung sitzt.
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const withDerivedStatus: ReportEmployee[] = [];
for (const e of data) {
const placement = placements.get(e.id);
@@ -128,6 +220,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
// sitzt aber auf keiner Planstelle mehr.
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue;
withDerivedStatus.push({
id: e.id,
@@ -153,6 +246,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
has_dienstwagen: e.has_dienstwagen,
is_laterale_fuehrung: e.is_laterale_fuehrung,
is_c_level: e.is_c_level,
teilzeit_art: e.teilzeit_art,
dependents_count: dependentsCounts.get(e.id) ?? 0,
});
}

View File

@@ -1,4 +1,4 @@
import { todayIso, yearsBetweenIso } from "./format";
import { fmtName, todayIso, yearsBetweenIso } from "./format";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/types";
export { todayIso };
@@ -25,6 +25,7 @@ export type GroupDimension =
| "laterale_fuehrung"
| "c_level"
| "has_dependents"
| "teilzeit_art"
| "weekday";
export const MEASURE_LABELS: Record<Measure, string> = {
@@ -55,6 +56,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
laterale_fuehrung: "Laterale Führung",
c_level: "C-Level",
has_dependents: "Hat Angehörige",
teilzeit_art: "Teilzeitvariante",
weekday: "Wochentag",
};
@@ -101,6 +103,7 @@ export type ReportEmployee = {
has_dienstwagen: boolean;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
teilzeit_art: string | null;
dependents_count: number;
};
@@ -108,6 +111,12 @@ export type ReportEmployee = {
// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und
// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette
// und wird einmal vorberechnet.
/**
* Eine Organisationseinheit als Filterwert — jede Ebene, nicht nur die
* Bereiche. `depth` dient der Einrückung in der Auswahlliste.
*/
export type UnitOption = { id: string; name: string; depth: number; unitType: string };
export type OrgLookups = {
divisionName: Map<string, string>;
departmentName: Map<string, string>;
@@ -176,6 +185,9 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
return e.is_c_level ? "Ja" : "Nein";
case "has_dependents":
return e.dependents_count > 0 ? "Ja" : "Nein";
case "teilzeit_art":
// „Keine" statt „–": die Antwort ist hier eine Aussage, kein Fehlen.
return e.teilzeit_art ?? "Keine";
case "weekday":
// Not a strict partition — see groupKeysFor, which aggregateReport
// actually uses. This single-key fallback only covers a direct
@@ -260,7 +272,7 @@ export function aggregateReport(
const value = measureValue(rowsForGroup, measure, asOf);
const people: ReportPerson[] = rowsForGroup.map((e) => ({
id: e.id,
name: `${e.first_name} ${e.last_name}`,
name: fmtName(e.first_name, e.last_name),
title: e.job_title,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
entry_date: e.entry_date,
@@ -398,7 +410,7 @@ export function aggregateEvents(
// code working unchanged for both report modes.
const people: ReportPerson[] = rowsForGroup.map((e) => ({
id: e.employee_id,
name: `${e.first_name} ${e.last_name}`,
name: fmtName(e.first_name, e.last_name),
title: e.description,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
entry_date: e.event_date,

76
lib/shell-data.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format";
import { baueOffeneNotizen, offeneNotizenAbfrage, type NotizZeile, type OpenNote } from "./notes";
import { buildOrgMaps, orgMapsAbfragen, type Location } from "./org";
import {
offeneStellenAbfrage,
resolveOpenPositions,
type OffeneStelle,
type OpenPositionResolved,
} from "./positions";
// Was die Hülle jeder Seite braucht — in zwei Rundreisen statt in sechs.
//
// Vorher stand das im Layout selbst, als Promise.all, das wie Gleichzeitigkeit
// aussah und keine war: eine Transaktion hängt an einer Verbindung, und über
// eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms Umlaufzeit
// kostete diese Hülle — die **jede** Seite mitlädt — eine halbe Sekunde
// Warten für ein paar Kilobyte. Der Weg dahin steht in lib/db/json.ts.
//
// Hier und nicht im Layout, damit sich die Zahl der Rundreisen messen lässt,
// ohne eine React-Komponente aufzubauen.
export type ShellData = {
profile: { full_name: string | null; email: string | null; role: string | null; is_active: boolean | null };
openPositions: OpenPositionResolved[];
locations: Location[];
drafts: { id: string; step: number; payload: Record<string, unknown>; updated_at: string }[];
openNotes: OpenNote[];
};
/**
* `null` heisst: angemeldet, aber nicht als HR freigeschaltet.
*
* Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest
* ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber
* weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS,
* nicht die Reihenfolge hier.
*/
export async function loadShellData(tx: Tx, userId: string): Promise<ShellData | null> {
const asOf = todayIso();
const gelesen = await tx
.selectNoFrom((eb) => [
jsonObjectFrom(
eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId)
).as("profile"),
...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"),
jsonArrayFrom(
eb
.selectFrom("hire_drafts")
.select(["id", "step", "payload"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("created_by", "=", userId)
.orderBy("updated_at", "desc")
).as("drafts"),
])
.executeTakeFirstOrThrow();
const profile = gelesen.profile;
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never);
return {
profile,
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
// offen sind — das lässt sich nicht in die erste ziehen.
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
locations: gelesen.locations as Location[],
drafts: gelesen.drafts as ShellData["drafts"],
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
};
}

View File

@@ -13,6 +13,49 @@ export type WorkerType = "Angestellte:r" | "Arbeiter:in";
export type CollectiveAgreement = "Handel" | "Süßwaren";
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
/**
* Antriebsart des Dienstwagens.
*
* Bewusst keine dritte Möglichkeit „unbekannt": die Angabe hängt per CHECK
* an has_dienstwagen, und wer einen Dienstwagen hat, weiss auch, ob er lädt
* oder tankt.
*/
export type DienstwagenArt = "Verbrenner" | "Elektro";
/**
* Gesetzlich geregelte Teilzeiten.
*
* Keine Abwesenheiten: die Person arbeitet, nur kürzer. Als Zustand geführt,
* damit sich auswerten lässt, wer gerade in einer ist — mit einem optionalen
* Enddatum, weil nicht jede ein bekanntes Ende hat.
*/
export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit";
/**
* Verhältnis zum Notfallkontakt.
*
* Auswahlliste statt Freitext, damit sich danach auswerten lässt und nicht
* „Gattin", „Ehefrau" und „Frau" nebeneinander stehen. „Sonstige" ist der
* Ausweg für alles, was hier fehlt — ohne ihn wäre die Liste eine Anmassung.
*
* In der Datenbank bleibt die Spalte `text`: eine Aufzählung dort würde jede
* Ergänzung zu einer Migration machen, und diese Liste wird sich ändern.
*/
export const EMERGENCY_RELATIONS = [
"Gattin/Gatte",
"Lebensgefährtin/Lebensgefährte",
"Mutter",
"Vater",
"Tochter",
"Sohn",
"Schwester",
"Bruder",
"Freundin/Freund",
"Sonstige",
] as const;
export type EmergencyRelation = (typeof EMERGENCY_RELATIONS)[number];
/**
* Eine einzelne Feldänderung im Protokoll.
*
@@ -106,11 +149,16 @@ export type Database = {
birth_date: string;
sv_nummer: string | null;
nationality: string;
/** Aufenthaltstitel — nur ausserhalb von EU, EWR und Schweiz erhoben. */
hat_aufenthaltstitel: boolean;
/** Gültig bis, falls befristet. Nur mit dem Kennzeichen zusammen erlaubt. */
aufenthaltstitel_bis: string | null;
address: string | null;
postal_code: string | null;
city: string | null;
address_country: string | null;
email: string;
/** Private Adresse, freiwillig — eindeutig, wenn angegeben. */
email: string | null;
phone: string | null;
job_title: string;
location_id: string;
@@ -135,6 +183,20 @@ export type Database = {
work_days: Weekday[];
is_betriebsrat: boolean;
has_dienstwagen: boolean;
/** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */
dienstwagen_art: DienstwagenArt | null;
/** Besonderer Kündigungsschutz — löst beim Austritt eine Warnung aus. */
has_kuendigungsschutz: boolean;
/** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */
kuendigungsschutz_bis: string | null;
/** Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen Regelung. */
teilzeit_art: TeilzeitArt | null;
/** Ende der Teilzeit, falls bekannt. Nur mit einer Variante zusammen erlaubt. */
teilzeit_bis: string | null;
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
emergency_contact_name: string | null;
emergency_contact_phone: string | null;
emergency_contact_relation: string | null;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
title_prefix: string[];
@@ -150,11 +212,13 @@ export type Database = {
birth_date: string;
sv_nummer?: string | null;
nationality?: string;
hat_aufenthaltstitel?: boolean;
aufenthaltstitel_bis?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;
address_country?: string | null;
email: string;
email?: string | null;
phone?: string | null;
job_title: string;
location_id: string;
@@ -177,6 +241,14 @@ export type Database = {
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
dienstwagen_art?: DienstwagenArt | null;
has_kuendigungsschutz?: boolean;
kuendigungsschutz_bis?: string | null;
teilzeit_art?: TeilzeitArt | null;
teilzeit_bis?: string | null;
emergency_contact_name?: string | null;
emergency_contact_phone?: string | null;
emergency_contact_relation?: string | null;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
title_prefix?: string[];
@@ -193,6 +265,10 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
/** Feldweise Änderungen — dieselbe Form wie audit_log.changes. */
changes: AuditChange[] | null;
/** Der geplante Vorgang, solange die Änderung noch nicht wirksam ist. */
pending_id: string | null;
created_at: string;
};
Insert: {
@@ -201,6 +277,8 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
changes?: AuditChange[] | null;
pending_id?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
@@ -401,6 +479,49 @@ export type Database = {
};
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
};
// Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der
// Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt.
cost_centers: NoRelationships & {
Row: {
id: string;
code: string;
name: string;
org_unit_id: string | null;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
code: string;
name: string;
org_unit_id?: string | null;
valid_from?: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["cost_centers"]["Insert"]>;
};
// A011: Planstelle kontiert auf Kostenstelle, zeitabhängig.
position_cost_centers: NoRelationships & {
Row: {
id: string;
position_id: string;
cost_center_id: string;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
position_id: string;
cost_center_id: string;
valid_from: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["position_cost_centers"]["Insert"]>;
};
// A008: Person besetzt Planstelle, zeitabhängig.
position_assignments: {
Row: {
@@ -454,9 +575,16 @@ export type Database = {
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
// Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige
// Weg an der fehlenden delete-Policy auf employee_history vorbei.
delete_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
// Berichtigt Wert und/oder Datum eines Historieneintrags.
update_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };

View File

@@ -44,8 +44,12 @@ function contentSecurityPolicy(): string {
const nextConfig: NextConfig = {
// Emits a self-contained .next/standalone server (only the deps actually
// used at runtime, no full node_modules) - what the Dockerfile copies in.
output: "standalone",
// used at runtime, no full node_modules) — what the Dockerfile copies in.
//
// Auf Vercel ist das falsch: dort baut die Plattform selbst und erwartet
// die übliche Ausgabe. `VERCEL` setzt sie in jeder Baustrecke, die Angabe
// entfällt dort also von selbst — und der Docker-Weg bleibt unberührt.
output: process.env.VERCEL ? undefined : "standalone",
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
// browser feature access) plus the report-only CSP described above.
async headers() {

View File

@@ -0,0 +1,132 @@
-- Eine Planstelle ändern.
--
-- Bisher liess sie sich nur anlegen und löschen. Ein Tippfehler in der
-- Tätigkeit oder ein falsches Gültigkeitsdatum bedeutete: löschen und neu —
-- mit neuer Planstellennummer. Die Nummer steht aber in Stellenausschreibungen
-- und Budgets, und die Protokollspur reisst ab.
--
-- Was hier bewusst **nicht** geht, steht als Sperre drin, nicht als
-- Anmerkung — siehe die drei Prüfungen unten.
create or replace function update_position(payload jsonb)
returns void
language plpgsql
set search_path = public, pg_temp
as $function$
declare
v_id uuid := (payload->>'position_id')::uuid;
v_alt om_positions%rowtype;
v_alt_titel text;
v_alt_einheit text;
v_org_unit_id uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
v_is_chief boolean;
v_valid_from date;
v_valid_to date;
v_job_id uuid;
v_unit_name text;
v_besetzt boolean;
v_changes jsonb := '[]'::jsonb;
begin
perform require_hr_admin();
select * into v_alt from om_positions where id = v_id;
if v_alt.id is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
select title into v_alt_titel from jobs where id = v_alt.job_id;
select name into v_alt_einheit from org_units where id = v_alt.org_unit_id;
-- Fehlende Schlüssel heissen „unverändert", nicht „leeren".
v_org_unit_id := coalesce(nullif(payload->>'org_unit_id','')::uuid, v_alt.org_unit_id);
v_job_title := coalesce(v_job_title, v_alt_titel);
v_is_chief := coalesce((payload->>'is_chief')::boolean, v_alt.is_chief);
v_valid_from := coalesce(nullif(payload->>'valid_from','')::date, v_alt.valid_from);
v_valid_to := case when payload ? 'valid_to' then nullif(payload->>'valid_to','')::date else v_alt.valid_to end;
select name into v_unit_name from org_units where id = v_org_unit_id;
if v_unit_name is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_valid_to is not null and v_valid_to < v_valid_from then
raise exception 'Das Ende der Gültigkeit liegt vor ihrem Beginn.';
end if;
select exists (
select 1 from position_assignments
where position_id = v_id and (valid_to is null or valid_to > current_date)
) into v_besetzt;
-- (1) Die Einheit einer vergebenen Planstelle zu wechseln wäre eine
-- Versetzung — mit allem, was dazugehört: Historie, Berichtslinie,
-- Protokoll. Das gehört in transfer_employee und nicht hierher, sonst
-- wandert jemand lautlos in eine andere Abteilung.
if v_besetzt and v_org_unit_id is distinct from v_alt.org_unit_id then
raise exception 'Diese Planstelle ist vergeben. Für einen Wechsel der Einheit die Versetzung benutzen.';
end if;
-- (2) Ein Ende, während noch jemand darauf sitzt, hinterlässt eine
-- Besetzung ohne Planstelle.
if v_besetzt and v_valid_to is not null then
raise exception 'Diese Planstelle ist vergeben und kann kein Ende der Gültigkeit bekommen.';
end if;
-- (3) Je Einheit nur eine gültige Leitung. Der Unique-Index fängt das auch,
-- aber mit einer Meldung, die in der Oberfläche nichts erklärt.
if v_is_chief and exists (
select 1 from om_positions
where org_unit_id = v_org_unit_id and is_chief and valid_to is null and id <> v_id
) then
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
end if;
-- Gleiche Tätigkeit, ein Katalogeintrag — dieselbe Regel wie beim Anlegen.
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
if v_job_id is null then
insert into jobs (code, title)
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
returning id into v_job_id;
end if;
v_changes := app_aenderung(v_changes, 'Tätigkeit', v_alt_titel, v_job_title);
v_changes := app_aenderung(v_changes, 'Organisationseinheit', v_alt_einheit, v_unit_name);
v_changes := app_aenderung(v_changes, 'Leitungsplanstelle', v_alt.is_chief::text, v_is_chief::text);
v_changes := app_aenderung(v_changes, 'Gültig ab', v_alt.valid_from::text, v_valid_from::text);
v_changes := app_aenderung(v_changes, 'Gültig bis', v_alt.valid_to::text, v_valid_to::text);
-- Nichts geändert ist ein Ergebnis, kein Erfolg.
--
-- Vorher kehrte die Funktion hier stumm zurück, und die Oberfläche meldete
-- „Planstelle geändert." Wer etwas eingetragen hatte, das unterwegs
-- verworfen wurde — etwa das Leitungshäkchen, das bei bereits vergebener
-- Leitung nicht durchkommt —, sah eine Erfolgsmeldung und eine unveränderte
-- Liste. Das ist genau die Rückmeldung, die einen suchen lässt.
if jsonb_array_length(v_changes) = 0 then
raise exception 'Es wurde nichts geändert.';
end if;
update om_positions
set org_unit_id = v_org_unit_id,
job_id = v_job_id,
is_chief = v_is_chief,
valid_from = v_valid_from,
valid_to = v_valid_to
where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
values (app_current_user_id(), current_actor_name(), 'Planstelle geändert',
v_job_title || ' (' || v_unit_name || ')',
app_aenderungsfelder(v_changes), v_changes);
end;
$function$;
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role', 'alpenwerk_app'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function update_position(jsonb) to %I', r);
end if;
end loop;
end;
$$;

View File

@@ -0,0 +1,104 @@
-- Die Fremdschlüssel von auth.users auf app_users umhängen.
--
-- Solange profiles.id auf auth.users zeigt, lässt sich **keine zweite Person
-- freischalten**. Die Anmeldung läuft über Auth.js und legt dort nichts mehr
-- an; wer sich neu anmeldet, bekommt eine app_users-Zeile, aber die dazu
-- nötige profiles-Zeile scheitert am Fremdschlüssel. Das Ergebnis wäre
-- „Kein HR-Zugriff" ohne Möglichkeit, es zu ändern.
--
-- Damit ist das hier keine Aufräumarbeit, sondern die Voraussetzung dafür,
-- dass die Anwendung mehr als eine Person bedienen kann.
--
-- Vorher geprüft: jeder referenzierte Wert steht bereits in app_users. Die
-- Umhängung ändert also keine Daten, nur die Zusicherung.
-- ═══ 1. Sicherheitsnetz ══════════════════════════════════════════
-- Was in app_users fehlt, wird aus profiles ergänzt. Im geprüften Bestand
-- ist das leer; die Migration soll aber auch auf einer Kopie laufen, in der
-- jemand zwischenzeitlich etwas angelegt hat.
insert into app_users (id, external_id, email, full_name)
select p.id, 'legacy:' || p.id::text, p.email, p.full_name
from profiles p
where not exists (select 1 from app_users a where a.id = p.id)
on conflict (id) do nothing;
-- ═══ 2. Umhängen ═════════════════════════════════════════════════
-- Über den Katalog statt acht handgeschriebene Anweisungen: die Namen der
-- Zwänge stammen aus verschiedenen Migrationen, und einer davon von Hand
-- falsch abgeschrieben hiesse, dass er stehen bleibt.
do $$
declare
r record;
v_delete text;
begin
for r in
select k.conname, c.relname as tabelle, a.attname as spalte, k.confdeltype
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
join pg_attribute a on a.attrelid = k.conrelid and a.attnum = any(k.conkey)
where k.contype = 'f'
and n.nspname = 'public'
and k.confrelid = (
select oid from pg_class
where relname = 'users'
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
)
loop
-- Das Löschverhalten bleibt, wie es war: profiles hängt kaskadierend am
-- Konto, die Protokoll- und Notizfelder nicht — dort soll ein Eintrag
-- gerade nicht verschwinden, weil ein Konto entfernt wird.
v_delete := case r.confdeltype when 'c' then ' on delete cascade' else '' end;
execute format('alter table %I drop constraint %I', r.tabelle, r.conname);
execute format('alter table %I add constraint %I foreign key (%I) references app_users(id)%s',
r.tabelle, r.conname, r.spalte, v_delete);
raise notice '%.% -> app_users%', r.tabelle, r.spalte, v_delete;
end loop;
end;
$$;
-- ═══ 3. Gegenprobe ═══════════════════════════════════════════════
do $$
declare
v_offen int;
v_neu int;
begin
select count(*) into v_offen
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
where k.contype = 'f' and n.nspname = 'public'
and k.confrelid = (
select oid from pg_class
where relname = 'users'
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
);
if v_offen > 0 then
raise exception '% Fremdschlüssel zeigen weiterhin auf auth.users.', v_offen;
end if;
select count(*) into v_neu
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
where k.contype = 'f' and n.nspname = 'public'
and k.confrelid = 'app_users'::regclass;
if v_neu < 8 then
raise exception 'Nur % Fremdschlüssel zeigen auf app_users — erwartet mindestens 8.', v_neu;
end if;
-- Und die eigentliche Frage: lässt sich jetzt eine zweite Person anlegen?
-- Geprüft und wieder entfernt, damit die Migration keine Daten hinterlässt.
declare
v_id uuid := gen_random_uuid();
begin
insert into app_users (id, external_id, email, full_name)
values (v_id, 'probe:' || v_id::text, 'probe@example.invalid', 'Probe');
insert into profiles (id, email, full_name, role, is_active)
values (v_id, 'probe@example.invalid', 'Probe', 'hr', false);
delete from profiles where id = v_id;
delete from app_users where id = v_id;
end;
end;
$$;

View File

@@ -0,0 +1,119 @@
-- Auf eine Planstelle darf nur besetzt werden, solange sie gilt.
--
-- Bisher prüften hire_employee und transfer_employee nur, ob die Stelle frei
-- ist — nicht, ob es sie zum fraglichen Zeitpunkt überhaupt gibt. Damit liess
-- sich heute jemand auf eine Planstelle einstellen, die erst im Oktober
-- entsteht, oder auf eine, die im Frühjahr ausgelaufen ist. Die Besetzung
-- stand dann in der Datenbank, die Stelle im Organigramm aber nicht, und die
-- Person hing an einer Struktur, die es zu ihrem Eintrittsdatum nicht gab.
--
-- Seit die Oberfläche künftige Planstellen anzeigt, ist das kein
-- theoretischer Fall mehr: sie stehen in derselben Auswahl.
--
-- Die Regel: das Datum der Besetzung — Eintritt bzw. Wirksamkeit der
-- Versetzung — muss in [valid_from, valid_to) liegen. `valid_to` ist wie
-- überall im Modell ausschliessend; eine Planstelle mit valid_to = heute gilt
-- heute nicht mehr (siehe lib/positions.ts).
--
-- Zweite Korrektur im selben Zug: die Belegungsprüfung sah nur Zuordnungen
-- mit offenem Ende. Eine, die erst später endet, blieb unsichtbar — dieselbe
-- Lücke, die die Übersicht der unbesetzten Planstellen hatte.
create or replace function app_funktion_ersetzen(p_funktion text, p_muster text, p_neu text)
returns void
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = p_funktion
limit 1;
if v_def is null then
raise exception 'Funktion %() nicht gefunden.', p_funktion;
end if;
-- Über ein Muster statt über festen Text, weil die Rümpfe je nach Herkunft
-- CRLF oder LF enthalten. Ein wörtlicher Vergleich fände dann nichts und
-- die Migration liefe erfolgreich durch, ohne etwas zu ändern.
v_neu := regexp_replace(v_def, p_muster, p_neu, 'g');
if v_neu = v_def then
raise exception 'In %() passte das Muster auf nichts — nichts geändert.', p_funktion;
end if;
execute v_neu;
end;
$$;
-- ═══ Eintritt ════════════════════════════════════════════════════
select app_funktion_ersetzen(
'hire_employee',
'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_position_id\s+and\s+pa\.valid_to\s+is\s+null;',
$neu$declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_entry < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
end if;
if v_bis is not null and v_entry >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_entry);$neu$
);
-- ═══ Versetzung ══════════════════════════════════════════════════
select app_funktion_ersetzen(
'transfer_employee',
'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_target_position\s+and\s+pa\.valid_to\s+is\s+null;',
$neu$declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_target_position;
if v_ab is null then
raise exception 'Die Zielplanstelle existiert nicht.';
end if;
if v_effective < v_ab then
raise exception 'Die Zielplanstelle gilt erst ab %. Eine Versetzung zum % ist darauf nicht möglich.', v_ab, v_effective;
end if;
if v_bis is not null and v_effective >= v_bis then
raise exception 'Die Zielplanstelle gilt nur bis %. Eine Versetzung zum % ist darauf nicht möglich.', v_bis, v_effective;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_target_position
and (pa.valid_to is null or pa.valid_to > v_effective);$neu$
);
drop function app_funktion_ersetzen(text, text, text);
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare r text;
begin
foreach r in array array['hire_employee', 'transfer_employee'] loop
if (select pg_get_functiondef(p.oid) from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = r limit 1) not like '%gilt erst ab%' then
raise exception '%() enthält die Gültigkeitsprüfung nicht.', r;
end if;
end loop;
end;
$$;

View File

@@ -0,0 +1,64 @@
-- Der Vorgabewert für die Arbeitstage in hire_employee greift nie.
--
-- coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::text[],
-- '{Mo,Di,Mi,Do,Fr}')
--
-- Fehlt der Schlüssel, liefert die Unterabfrage keine Zeilen, und `array(…)`
-- macht daraus ein **leeres** Array — nicht NULL. `coalesce` sieht also
-- keinen fehlenden Wert und lässt `{}` stehen. Die Bedingung
-- chk_work_days_valid verlangt aber mindestens einen Tag, und die
-- Einstellung bricht ab.
--
-- Sichtbar ist das bisher nicht, weil der Assistent die Arbeitstage immer
-- mitschickt und ohne sie gar nicht weiterlässt. Es ist eine Falle für jeden
-- anderen Aufrufer — und ein Vorgabewert, der nichts vorgibt, ist schlimmer
-- als keiner: er sieht aus, als wäre der Fall bedacht.
--
-- `nullif(…, '{}')` macht aus dem leeren Array wieder ein fehlendes.
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee'
limit 1;
v_neu := regexp_replace(
v_def,
'coalesce\(\s*array\(\s*select\s+jsonb_array_elements_text\(payload->''work_days''\)\s*\)\s*::text\[\]\s*,',
'coalesce(nullif(array(select jsonb_array_elements_text(payload->''work_days''))::text[], ''{}''),',
'g'
);
if v_neu = v_def then
raise exception 'Das Muster für die Arbeitstage passte nicht — hire_employee blieb unverändert.';
end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Der Ausdruck in beiden Formen, damit die Regel festgehalten ist und nicht
-- beim nächsten Mal neu entdeckt werden muss.
do $$
begin
if array(select jsonb_array_elements_text('{}'::jsonb->'work_days')) is null then
raise exception 'array() über einen fehlenden Schlüssel liefert null — die Annahme dieser Migration stimmt nicht mehr.';
end if;
if coalesce(nullif(array(select jsonb_array_elements_text('{}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}')
<> '{Mo,Di,Mi,Do,Fr}'::text[] then
raise exception 'Der korrigierte Ausdruck liefert nicht die Vorgabe.';
end if;
if coalesce(nullif(array(select jsonb_array_elements_text('{"work_days":["Mo","Di"]}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}')
<> '{Mo,Di}'::text[] then
raise exception 'Der korrigierte Ausdruck überschreibt einen mitgegebenen Wert.';
end if;
end;
$$;

View File

@@ -0,0 +1,82 @@
-- Die Wiedereinstellung prüft die Zielplanstelle wie Eintritt und Versetzung.
--
-- rehire_employee verlangte zwar eine Planstelle, setzte die Besetzung dann
-- aber ungeprüft: weder ob die Stelle zum Wiedereintritt gilt noch ob sie
-- frei ist. Eine wiedereingestellte Person konnte damit auf einer bereits
-- besetzten Stelle landen — abgefangen erst vom Teilindex, mit einer Meldung,
-- die in der Oberfläche nichts erklärt — oder auf einer, die es zu dem Datum
-- gar nicht gibt.
--
-- Dieselbe Regel wie in 20260810100000: das Datum muss in
-- [valid_from, valid_to) liegen, und es darf keine Zuordnung geben, die zu
-- diesem Zeitpunkt noch gilt.
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee'
limit 1;
if v_def is null then
raise exception 'rehire_employee() nicht gefunden.';
end if;
-- Über ein Muster, weil der Rumpf CRLF enthalten kann; ein wörtlicher
-- Vergleich fände nichts und die Migration meldete trotzdem Erfolg.
v_neu := regexp_replace(
v_def,
'if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Für die Wiedereinstellung muss eine Planstelle angegeben werden\.'';\s+end\s+if;',
$neu$if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
declare
v_ab date;
v_bis date;
v_besetzt uuid;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_date < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Wiedereintritt am % ist darauf nicht möglich.', v_ab, v_date;
end if;
if v_bis is not null and v_date >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Wiedereintritt am % ist darauf nicht möglich.', v_bis, v_date;
end if;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_date);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
end;$neu$,
'g'
);
if v_neu = v_def then
raise exception 'Das Muster passte nicht — rehire_employee blieb unverändert.';
end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
begin
if (select pg_get_functiondef(p.oid) from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee' limit 1) not like '%gilt erst ab%' then
raise exception 'rehire_employee() enthält die Gültigkeitsprüfung nicht.';
end if;
end;
$$;

View File

@@ -0,0 +1,65 @@
-- rehire_employee funktionierte nie.
--
-- status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end
--
-- Der case-Ausdruck ist `text`, die Spalte ist `employment_status`. Postgres
-- weist das ab:
--
-- column "status" is of type employment_status but expression is of type text
--
-- Sichtbar wurde es erst jetzt. Davor brach die Funktion eine Zeile früher ab,
-- weil das Formular nie eine Planstelle mitschickte — ein Fehler verdeckte den
-- anderen, wie schon bei hire_employee.
--
-- Bei den beiden anderen Zuweisungen im selben update (exit_date, exit_reason)
-- stellt sich die Frage nicht: `null` ist typunabhängig.
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee'
limit 1;
v_neu := regexp_replace(
v_def,
'(case\s+when\s+v_date\s*<=\s*current_date\s+then\s+''Aktiv''\s+else\s+''Geplant''\s+end)',
'(\1)::employment_status',
'g'
);
if v_neu = v_def then
raise exception 'Das Muster für den Status passte nicht — rehire_employee blieb unverändert.';
end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Nachgestellt wird die **Spaltenzuweisung**, nicht die an eine Variable.
-- Das ist der Unterschied, an dem mein erster Prüfausdruck vorbeiging:
-- plpgsql wandelt bei einer Variablenzuweisung stillschweigend um, ein
-- UPDATE auf eine Spalte nicht. Nur die zweite Form entspricht dem Fehler.
do $$
begin
create temp table probe_status (s employment_status) on commit drop;
insert into probe_status values ('Aktiv');
begin
execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::text $probe$;
raise exception 'Eine text-Zuweisung an eine employment_status-Spalte wirft nicht mehr — die Prüfung ist wertlos geworden.';
exception
when datatype_mismatch then null; -- erwartet
end;
execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::employment_status $probe$;
if (select s from probe_status) <> 'Aktiv'::employment_status then
raise exception 'Die umgewandelte Form liefert nicht Aktiv.';
end if;
end;
$$;

View File

@@ -0,0 +1,120 @@
-- Die Personalnummer wird eingegeben, nicht vergeben.
--
-- Sie muss mit Loga und Interflex übereinstimmen. Eine von dieser Anwendung
-- selbst gezogene Nummer ist dort unbekannt, und die Person hätte in drei
-- Systemen zwei Nummern.
--
-- Zwei Dinge ändern sich dadurch:
--
-- 1. Die Identität fällt weg. `GENERATED ALWAYS` weist eigene Werte
-- ausdrücklich ab — deshalb brauchte der Import bisher OVERRIDING SYSTEM
-- VALUE.
-- 2. Die Eindeutigkeit muss ausdrücklich her. Bisher gab es **keine**: die
-- Identität verhinderte Doppelte nur als Nebenwirkung. Sobald der Wert von
-- aussen kommt, ist das die eigentliche Zusicherung — und sie fehlte.
-- Gegenprobe vor dem Umbau: was jetzt doppelt ist, liesse sich danach nicht
-- mehr eindeutig machen.
do $$
declare v_doppelt int;
begin
select count(*) into v_doppelt from (
select personnel_number from employees group by 1 having count(*) > 1
) x;
if v_doppelt > 0 then
raise exception '% Personalnummern kommen mehrfach vor — vor der Umstellung bereinigen.', v_doppelt;
end if;
end;
$$;
alter table employees alter column personnel_number drop identity if exists;
alter table employees add constraint employees_personnel_number_key unique (personnel_number);
comment on column employees.personnel_number is
'Wird eingegeben und muss mit Loga/Interflex übereinstimmen. Nicht automatisch vergeben.';
-- ═══ hire_employee nimmt die Nummer entgegen ═════════════════════
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
-- Pflichtprüfung direkt nach der Planstellenprüfung einhängen.
v_neu := regexp_replace(
v_def,
'(if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Es muss eine Planstelle angegeben werden\.'';\s+end\s+if;)',
$neu$\1
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
raise exception 'Es muss eine Personalnummer angegeben werden.';
end if;
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
end if;$neu$,
'g'
);
if v_neu = v_def then
raise exception 'Die Pflichtprüfung liess sich nicht einhängen — hire_employee blieb unverändert.';
end if;
v_def := v_neu;
-- Und in die Einfügung aufnehmen. Die Spaltenliste beginnt mit
-- first_name; davor kommt personnel_number, in beiden Listen an gleicher
-- Stelle.
v_neu := regexp_replace(v_def, 'insert\s+into\s+employees\s*\(\s*first_name,', 'insert into employees (' || chr(10) || ' personnel_number, first_name,', 'g');
if v_neu = v_def then
raise exception 'Die Spaltenliste liess sich nicht ergänzen.';
end if;
v_def := v_neu;
v_neu := regexp_replace(v_def, 'values\s*\(\s*payload->>''first_name'',', 'values (' || chr(10) || ' (payload->>''personnel_number'')::int, payload->>''first_name'',', 'g');
if v_neu = v_def then
raise exception 'Die Werteliste liess sich nicht ergänzen.';
end if;
-- OVERRIDING SYSTEM VALUE gibt es nur für Identitätsspalten; nach dem
-- Wegfall wäre es ein Fehler. Der Import setzt es selbst nicht mehr, hier
-- steht es vorsorglich, falls eine ältere Fassung es doch trägt.
v_neu := regexp_replace(v_neu, '\s+overriding\s+system\s+value', '', 'gi');
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare
v_ist_identitaet text;
v_def text;
begin
select is_identity into v_ist_identitaet
from information_schema.columns
where table_name = 'employees' and column_name = 'personnel_number';
if v_ist_identitaet <> 'NO' then
raise exception 'personnel_number ist weiterhin eine Identitätsspalte.';
end if;
if not exists (
select 1 from pg_constraint
where conrelid = 'employees'::regclass and contype = 'u'
and pg_get_constraintdef(oid) = 'UNIQUE (personnel_number)'
) then
raise exception 'Die Eindeutigkeit auf personnel_number fehlt.';
end if;
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
if v_def not like '%bereits vergeben%' then
raise exception 'hire_employee() prüft die Personalnummer nicht.';
end if;
if v_def ilike '%overriding system value%' then
raise exception 'hire_employee() enthält weiterhin OVERRIDING SYSTEM VALUE.';
end if;
end;
$$;

View File

@@ -0,0 +1,96 @@
-- Notfallkontakt und Antriebsart des Dienstwagens.
-- ═══ Notfallkontakt ══════════════════════════════════════════════
-- Als Spalten und nicht als eigene Tabelle: gefragt war *der* Notfallkontakt,
-- einer je Person. Eine Tabelle wäre die richtige Antwort auf „mehrere", und
-- die Frage stellt sich hier nicht.
--
-- Das Verhältnis bleibt Freitext. Die Beispiele — Gattin/Gatte,
-- Schwester/Bruder, Freund — sind keine Aufzählung, die sich schliessen
-- lässt, ohne jemandem die eigene Lebensform abzusprechen.
alter table employees add column if not exists emergency_contact_name text;
alter table employees add column if not exists emergency_contact_phone text;
alter table employees add column if not exists emergency_contact_relation text;
comment on column employees.emergency_contact_name is
'Notfallkontakt: Name. Daten einer dritten Person — nur für den Notfall erhoben.';
-- Alles oder nichts: ein Name ohne Nummer nützt im Notfall nichts, eine
-- Nummer ohne Namen sagt nicht, wen man da anruft.
alter table employees drop constraint if exists chk_emergency_contact;
alter table employees add constraint chk_emergency_contact check (
(emergency_contact_name is null and emergency_contact_phone is null)
or (btrim(coalesce(emergency_contact_name, '')) <> '' and btrim(coalesce(emergency_contact_phone, '')) <> '')
);
-- ═══ Antriebsart des Dienstwagens ════════════════════════════════
-- Eine zweite Spalte statt eines Umbaus von has_dienstwagen: die bestehende
-- Angabe bleibt gültig, und alle Auswertungen darauf ebenfalls.
alter table employees add column if not exists dienstwagen_art text;
comment on column employees.dienstwagen_art is
'Antriebsart des Dienstwagens: Verbrenner oder Elektro. Null, wenn keiner vorhanden.';
-- Der CHECK bindet die beiden Angaben aneinander. Ohne ihn stünde irgendwann
-- „E-KFZ" bei jemandem ohne Dienstwagen, und niemand wüsste, welche der
-- beiden Angaben stimmt.
-- Zuerst den Bestand füllen, dann prüfen — andersherum weist die Bedingung
-- jede vorhandene Zeile mit Dienstwagen ab. Bestehende gelten als
-- Verbrenner, bis jemand es besser weiss; das ist eine Annahme, aber eine
-- sichtbare: „Elektro" steht nirgends, wo es niemand bestätigt hat.
update employees set dienstwagen_art = 'Verbrenner'
where has_dienstwagen and dienstwagen_art is null;
-- `is not null and` steht bewusst davor: `dienstwagen_art in (…)` ergibt bei
-- NULL nicht `false`, sondern NULL — und ein CHECK gilt als erfüllt, wenn er
-- NULL liefert. Ohne die ausdrückliche Prüfung hätte diese Bedingung genau
-- den Fall durchgelassen, gegen den sie geschrieben ist. Aufgefallen ist das
-- der Gegenprobe am Ende dieser Datei, nicht mir.
alter table employees drop constraint if exists chk_dienstwagen_art;
alter table employees add constraint chk_dienstwagen_art check (
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner', 'Elektro'))
or (not has_dienstwagen and dienstwagen_art is null)
);
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
begin
create temp table probe_emp (
has_dienstwagen boolean not null default false,
dienstwagen_art text,
emergency_contact_name text,
emergency_contact_phone text,
constraint p_art check (
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner','Elektro'))
or (not has_dienstwagen and dienstwagen_art is null)),
constraint p_kontakt check (
(emergency_contact_name is null and emergency_contact_phone is null)
or (btrim(coalesce(emergency_contact_name,'')) <> '' and btrim(coalesce(emergency_contact_phone,'')) <> ''))
) on commit drop;
-- E-KFZ ohne Dienstwagen muss abgewiesen werden.
begin
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (false, 'Elektro');
raise exception 'Antriebsart ohne Dienstwagen wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
-- Dienstwagen ohne Antriebsart ebenso.
begin
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, null);
raise exception 'Dienstwagen ohne Antriebsart wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, 'Elektro');
-- Name ohne Nummer muss abgewiesen werden.
begin
insert into probe_emp (emergency_contact_name) values ('Maria Muster');
raise exception 'Notfallkontakt ohne Nummer wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
insert into probe_emp (emergency_contact_name, emergency_contact_phone) values ('Maria Muster', '+43 660 1234567');
end;
$$;

View File

@@ -0,0 +1,91 @@
-- change_employee_data kennt Notfallkontakt und Antriebsart.
--
-- Ohne diesen Schritt liessen sich die neuen Felder anlegen, aber nie
-- ändern: die Funktion vergleicht und schreibt namentlich aufgezählte
-- Spalten, und was dort fehlt, wird stillschweigend übergangen. Das ist die
-- unangenehme Sorte Lücke — die Oberfläche zeigt ein Eingabefeld, das
-- Speichern meldet Erfolg, und der Wert bleibt stehen.
--
-- Beide Ergänzungen laufen über app_aenderung(), landen also mit Vorher und
-- Nachher im Protokoll wie alles andere auch.
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' limit 1;
-- ── Vergleiche: Notfallkontakt hinter Telefon, Antriebsart hinter C-Level
v_neu := regexp_replace(
v_def,
'(if\s+v_person\s+\?\s+''phone''\s+then\s+v_person_changes\s*:=\s*app_aenderung\(v_person_changes,\s*''Telefon'',\s*v_old\.phone,\s*v_person->>''phone''\);\s*end\s+if;)',
$neu$\1
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Vergleichsblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
v_def := v_neu;
v_neu := regexp_replace(
v_def,
'(if\s+v_role\s+\?\s+''is_c_level''\s+then\s+v_contract_changes\s*:=\s*app_aenderung\(v_contract_changes,\s*''C-Level'',\s*v_old\.is_c_level::text,\s*v_role->>''is_c_level''\);\s*end\s+if;)',
$neu$\1
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Vergleich für die Antriebsart liess sich nicht einhängen.'; end if;
v_def := v_neu;
-- ── Schreiben
v_neu := regexp_replace(
v_def,
'(phone\s*=\s*coalesce\(v_person->>''phone'',\s*phone\),)',
$neu$\1
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Schreibblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
v_def := v_neu;
-- Die Antriebsart hängt an has_dienstwagen: wird der Dienstwagen
-- abgemeldet, muss sie mit, sonst weist der CHECK die Zeile ab.
v_neu := regexp_replace(
v_def,
'(is_c_level\s*=\s*coalesce\(\(v_role->>''is_c_level''\)::boolean,\s*is_c_level\))',
$neu$\1,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Schreibblock für die Antriebsart liess sich nicht einhängen.'; end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'change_employee_data' limit 1;
if v_def not like '%Notfallkontakt Verhältnis%' then
raise exception 'change_employee_data() vergleicht den Notfallkontakt nicht.';
end if;
if v_def not like '%dienstwagen_art = case%' then
raise exception 'change_employee_data() schreibt die Antriebsart nicht.';
end if;
end;
$$;

View File

@@ -0,0 +1,62 @@
-- hire_employee schreibt Notfallkontakt und Antriebsart mit.
--
-- Die Spalten gibt es seit 20260811110000, aber die Einstellung zählt ihre
-- Spalten namentlich auf — was dort fehlt, wird beim Anlegen verworfen. Der
-- Assistent hätte die Felder erhoben und stillschweigend weggeworfen; genau
-- der Fall, der heute schon einmal mit der E-Mail passiert ist.
do $$
declare
v_def text;
v_neu text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
v_neu := regexp_replace(
v_def,
'(is_betriebsrat,\s*has_dienstwagen,\s*is_laterale_fuehrung,\s*is_c_level\s*\))',
'is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,'
|| chr(10) || ' dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation'
|| chr(10) || ' )',
'g'
);
if v_neu = v_def then raise exception 'Die Spaltenliste liess sich nicht ergänzen.'; end if;
v_def := v_neu;
-- Die Werteliste endet mit is_c_level; davor steht der Abschluss der
-- values-Klammer.
v_neu := regexp_replace(
v_def,
'(coalesce\(\(payload->>''is_c_level''\)::boolean,\s*false\))',
E'\\1,\n'
-- Antrieb nur, wenn es einen Dienstwagen gibt — sonst weist der CHECK
-- die Zeile ab. Ohne Angabe gilt Verbrenner, wie im Bestand.
|| ' case when coalesce((payload->>''has_dienstwagen'')::boolean, false)'
|| ' then coalesce(nullif(payload->>''dienstwagen_art'', ''''), ''Verbrenner'') else null end,' || chr(10)
|| ' nullif(payload->>''emergency_contact_name'', ''''),' || chr(10)
|| ' nullif(payload->>''emergency_contact_phone'', ''''),' || chr(10)
|| ' nullif(payload->>''emergency_contact_relation'', '''')',
'g'
);
if v_neu = v_def then raise exception 'Die Werteliste liess sich nicht ergänzen.'; end if;
execute v_neu;
end;
$$;
do $$
declare v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
if v_def not like '%emergency_contact_relation%' then
raise exception 'hire_employee() schreibt den Notfallkontakt nicht.';
end if;
if v_def not like '%dienstwagen_art%' then
raise exception 'hire_employee() schreibt die Antriebsart nicht.';
end if;
end;
$$;

View File

@@ -0,0 +1,37 @@
-- Die E-Mail-Adresse ist privat und freiwillig.
--
-- Sie war NOT NULL, obwohl es sich um die *private* Adresse handelt — nicht
-- um eine Firmenadresse, die mit dem Eintritt entsteht. Wer keine angeben
-- will oder keine hat, muss trotzdem angelegt werden können. Bisher zwang
-- die Spalte dazu, etwas zu erfinden, und erfundene Daten in einer
-- Personalakte sind schlimmer als fehlende.
--
-- Die Eindeutigkeit bleibt: sie verhindert weiterhin, dass dieselbe Adresse
-- zweimal vorkommt. Mehrere NULL-Werte stören sie nicht — in PostgreSQL
-- gelten sie in einem UNIQUE-Index als voneinander verschieden, und genau
-- das ist hier gewollt.
alter table employees alter column email drop not null;
comment on column employees.email is
'Private E-Mail-Adresse. Freiwillig; eindeutig, wenn angegeben.';
comment on column employees.phone is
'Private Telefonnummer. Freiwillig.';
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Zwei Personen ohne Adresse müssen nebeneinander bestehen können, zwei mit
-- derselben nicht.
do $$
begin
create temp table probe_mail (email text unique) on commit drop;
insert into probe_mail (email) values (null), (null);
insert into probe_mail (email) values ('a@example.invalid');
begin
insert into probe_mail (email) values ('a@example.invalid');
raise exception 'Doppelte Adressen werden angenommen — die Eindeutigkeit ist verloren.';
exception when unique_violation then null;
end;
end;
$$;

View File

@@ -0,0 +1,179 @@
-- Die Historie trägt die Werte mit, nicht nur die Feldnamen.
--
-- Beim Testen fiel auf: ändert jemand eine Adresse, steht in der Historie der
-- Person nur "Geänderte Felder: Adresse, Ort". Die alte Adresse ist nirgends
-- zu sehen — sie steckt allein im Audit-Log, und das ist eine andere Seite,
-- nach Zeitpunkt und handelnder Person sortiert statt nach Person. Wer wissen
-- will, ob eine Anschrift je geändert wurde und wie sie vorher lautete, kommt
-- also nicht hin, obwohl die Anwendung es weiß.
--
-- Die Feldliste wird beim Ändern ohnehin gebaut (app_aenderung) und ins
-- Audit-Log geschrieben. Sie wandert jetzt zusätzlich in die Historienzeile.
-- Das ist bewusst redundant: die Historie ist die Geschichte *einer Person*
-- und soll für sich allein lesbar sein — auch dann noch, wenn das Protokoll
-- irgendwann nach Aufbewahrungsfrist ausgedünnt wird.
--
-- Alte Zeilen bleiben ohne Werte. Nachliefern ließe sich das nur aus dem
-- Audit-Log, und die Zuordnung dorthin ist nicht eindeutig (kein Schlüssel,
-- nur Zeitpunkt und Person). Lieber ehrlich leer als falsch verknüpft.
alter table employee_history add column if not exists changes jsonb;
comment on column employee_history.changes is
'Feldweise Änderungen als [{feld, vorher, nachher}] — dieselbe Form wie audit_log.changes. Null bei Ereignissen ohne Einzelfelder (Eintritt, Austritt, Import) und bei Zeilen von vor dieser Migration.';
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- Selbstprüfung: schlägt die Migration fehl, statt still nichts zu tun.
do $$
declare
v_def text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employee_history' and column_name = 'changes'
) then
raise exception 'employee_history.changes fehlt';
end if;
if (length(v_def) - length(replace(v_def, 'description, changes)', ''))) / length('description, changes)') <> 2 then
raise exception 'change_employee_data schreibt changes nicht in beide Historien-Einträge';
end if;
end
$$;

View File

@@ -0,0 +1,209 @@
-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung.
--
-- Die Historie ist bewusst fortschreibend: employee_history hat nur Policies
-- für select und insert, es gibt kein update und kein delete. Das bleibt so.
-- Was hier entsteht, ist ein einzelner kontrollierter Weg daran vorbei, und
-- er ist eng: nur eine irrtümlich erfasste Stammdaten- oder Vertragsänderung,
-- nur mit Feldwerten, nur wenn sie bereits wirksam ist.
--
-- ═══ Warum überhaupt löschen ═══
--
-- Eine Adresse, die versehentlich geändert wurde, steht sonst für immer als
-- Änderung in der Akte — und die Person wohnt an der falschen Anschrift, bis
-- jemand sie von Hand zurücksetzt. Dieses Zurücksetzen erzeugt dann eine
-- *zweite* Änderung, und in der Historie stehen zwei Einträge, von denen
-- keiner je stattgefunden hat. Genau das soll der Vorgang hier ersparen.
--
-- ═══ „Die letztgültige Änderung ist die schlagende" ═══
--
-- Beim Zurücknehmen wird je Feld einzeln entschieden:
--
-- * Hat ein **späterer** Eintrag dasselbe Feld angefasst, bleibt der
-- heutige Wert stehen — die spätere Änderung ist die gültige.
-- * Sonst wird der Wert auf das „vorher" des gelöschten Eintrags gesetzt.
--
-- Deshalb lässt sich auch der mittlere von drei Einträgen entfernen, ohne
-- dass ein alter Wert einen neueren überschreibt.
--
-- ═══ Was nicht geht, und warum ═══
--
-- * **Eintritt** — der Anker der Zeitleiste. Ohne ihn hat die Person keinen
-- Anfang, und ein Trigger verbietet ohnehin Ereignisse davor.
-- * **Zukünftiges** — dazu gehört eine Zeile in pending_org_changes, und
-- die lässt sich einem Historieneintrag nicht zuverlässig zuordnen: es
-- gibt keinen Schlüssel zwischen beiden, nur Person und Datum. In den
-- Daten hängt bereits ein „Eintritt" und eine Vertragsänderung am selben
-- Tag. Eine Zuordnung über das Datum träfe irgendwann die falsche Zeile,
-- und dann verschwände eine geplante Änderung, die niemand gemeint hat.
-- * **Versetzung, Beförderung, Karenz, Rückkehr, Austritt, Wiedereintritt,
-- Reorganisation** — die haben Planstellen und Zuordnungen bewegt.
-- Dafür gibt es die fachlichen Vorgänge, die das sauber fortschreiben,
-- statt rückwärts zu raten.
-- * **Einträge ohne Feldwerte** — alles vor der Erweiterung der Historie.
-- Es gibt nichts, worauf zurückgesetzt werden könnte.
--
-- ═══ Der Nachweis bleibt ═══
--
-- Gelöscht wird die Historienzeile, nicht die Spur: das Audit-Log bekommt
-- einen Eintrag mit den Werten der gelöschten Zeile. Das Protokoll ist selbst
-- fortschreibend, dort kann nichts verschwinden.
create or replace function delete_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
v_karte constant jsonb := jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text'),
'Nachname', jsonb_build_array('last_name', 'text'),
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
'Adresse', jsonb_build_array('address', 'text'),
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
'Ort', jsonb_build_array('city', 'text'),
'Land', jsonb_build_array('address_country', 'text'),
'E-Mail', jsonb_build_array('email', 'text'),
'Telefon', jsonb_build_array('phone', 'text'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
);
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
-- angefasst hat. Sonst gilt der spätere Wert weiter.
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue; -- unbekannte Beschriftung: nichts anfassen
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
execute format('update employees set %I = coalesce(string_to_array(%L, '', ''), ''{}'') where id = %L',
v_spalte, nullif(v_wert, ''), v_eintrag.employee_id);
else
execute format('update employees set %I = %L::%s where id = %L',
v_spalte, nullif(v_wert, ''), v_typ, v_eintrag.employee_id);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
comment on function delete_history_entry(jsonb) is
'Nimmt eine irrtümliche Stammdaten- oder Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat, und entfernt die Historienzeile. Der Vorgang selbst wird im Audit-Log festgehalten. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.';
-- Selbstprüfung: lieber laut scheitern als still nichts tun.
do $$
declare
v_def text;
begin
select pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure) into v_def;
if not (select prosecdef from pg_proc where oid = 'public.delete_history_entry(jsonb)'::regprocedure) then
raise exception 'delete_history_entry muss SECURITY DEFINER sein, sonst greift die fehlende delete-Policy';
end if;
if v_def not like '%require_hr_admin%' then
raise exception 'delete_history_entry prüft die Berechtigung nicht';
end if;
-- Die delete-Policy darf es weiterhin nicht geben: der Weg hier ist der
-- einzige, und er ist geprüft.
if exists (
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
where c.relname = 'employee_history' and p.polcmd = 'd'
) then
raise exception 'employee_history hat eine delete-Policy bekommen — das war nicht beabsichtigt';
end if;
end
$$;

View File

@@ -0,0 +1,185 @@
-- Zurücksetzen in einem Zug, nicht Feld für Feld.
--
-- Die erste Fassung schrieb je Feld ein eigenes UPDATE. Das ist bei
-- unabhängigen Feldern harmlos und bei gekoppelten falsch: chk_weekly_hours
-- verlangt, dass Beschäftigungsausmaß und Wochenstunden zueinander passen
-- (Vollzeit genau 38,5; Teilzeit dazwischen). Wird zuerst das Ausmaß auf
-- „Vollzeit" zurückgesetzt, während noch 37 Stunden dastehen, verbietet die
-- Bedingung genau diesen Zwischenstand — und das Löschen scheiterte mit
-- „new row for relation employees violates check constraint".
--
-- Es traf jede Rücknahme einer Vertragsänderung, die beide Felder betraf,
-- also praktisch jede: die Oberfläche ändert Ausmaß und Stunden zusammen.
--
-- Jetzt werden die Zuweisungen gesammelt und in einer einzigen Anweisung
-- geschrieben. Damit entsteht der verbotene Zwischenstand gar nicht — der
-- Zustand von davor war ja gültig, sonst stünde er nicht in der Historie.
--
-- Bleibt danach doch eine Verletzung, ist sie echt: dann hat eine spätere
-- Änderung eines der gekoppelten Felder einzeln angefasst, und der alte Wert
-- passt nicht mehr zum heutigen Stand. Dieser Fall wird abgefangen und als
-- Satz gemeldet, statt als Datenbankfehler durchzuschlagen.
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
v_karte constant jsonb := jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text'),
'Nachname', jsonb_build_array('last_name', 'text'),
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
'Adresse', jsonb_build_array('address', 'text'),
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
'Ort', jsonb_build_array('city', 'text'),
'Land', jsonb_build_array('address_country', 'text'),
'E-Mail', jsonb_build_array('email', 'text'),
'Telefon', jsonb_build_array('phone', 'text'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
);
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
-- angefasst hat. Sonst gilt der spätere Wert weiter.
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue; -- unbekannte Beschriftung: nichts anfassen
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der
-- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit
-- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
-- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen-
-- gehörenden Feldern später einzeln geändert, und der alte Wert passt
-- nicht mehr zum heutigen Stand. Lieber verständlich abweisen.
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_def text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
begin
if v_def like '%update employees set %I%' then
raise exception 'Es wird noch je Feld einzeln geschrieben';
end if;
if v_def not like '%array_to_string(v_setz%' then
raise exception 'Das gesammelte UPDATE fehlt';
end if;
if v_def not like '%check_violation%' then
raise exception 'Die Verletzung wird nicht abgefangen';
end if;
end
$$;

View File

@@ -0,0 +1,366 @@
-- Historieneinträge berichtigen — und die Feldtabelle nur noch einmal führen.
--
-- Drei Teile: die gemeinsame Feldtabelle, die bisher im Rumpf der
-- Löschfunktion stand; dieselbe Löschfunktion, nun auf die gemeinsame
-- Tabelle umgestellt; und das Berichtigen als neue Funktion.
-- Die Feldtabelle einmal, für alle, die sie brauchen.
--
-- Beschriftung → Spalte und Typ. Sie stand bisher im Rumpf von
-- delete_history_entry; mit dem Bearbeiten kam eine zweite Stelle dazu, die
-- sie genauso braucht. Zwei Kopien einer solchen Liste laufen auseinander,
-- sobald ein Feld hinzukommt — und dann lässt sich ein Feld löschen, aber
-- nicht korrigieren, ohne dass es jemandem auffällt.
--
-- Geschlossene Liste: was change_employee_data schreiben kann, steht hier,
-- sonst nichts. Die Spaltennamen gehen in dynamisches SQL und dürfen nur
-- von hier kommen.
create or replace function app_feld_karte()
returns jsonb
language sql
immutable
set search_path to 'public', 'pg_temp'
as $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text'),
'Nachname', jsonb_build_array('last_name', 'text'),
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
'Adresse', jsonb_build_array('address', 'text'),
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
'Ort', jsonb_build_array('city', 'text'),
'Land', jsonb_build_array('address_country', 'text'),
'E-Mail', jsonb_build_array('email', 'text'),
'Telefon', jsonb_build_array('phone', 'text'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
);
$function$;
comment on function app_feld_karte() is 'Beschriftung eines Feldes → [Spalte, Typ]. Quelle für das Zurücksetzen und Korrigieren von Historieneinträgen.';
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
v_karte constant jsonb := app_feld_karte();
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
-- angefasst hat. Sonst gilt der spätere Wert weiter.
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue; -- unbekannte Beschriftung: nichts anfassen
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der
-- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit
-- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
-- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen-
-- gehörenden Feldern später einzeln geändert, und der alte Wert passt
-- nicht mehr zum heutigen Stand. Lieber verständlich abweisen.
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
-- Einen Historieneintrag berichtigen.
--
-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier
-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert
-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu
-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung,
-- von denen der erste nie stattgefunden hat.
--
-- Geändert werden darf das **Nachher** und das **Datum**. Das Vorher bleibt:
-- es beschreibt, was vor der Änderung galt, und das lässt sich nachträglich
-- nicht anders beschliessen.
--
-- ═══ Wie der heutige Stand danach zustande kommt ═══
--
-- Nicht durch Zurückrechnen, sondern durch Nachsehen: für jedes betroffene
-- Feld gewinnt der **jüngste** Historieneintrag, der es trägt. Das ist
-- dieselbe Regel wie beim Löschen — „die letztgültige Änderung ist die
-- schlagende" — nur von der anderen Seite gelesen, und sie trägt hier
-- zusätzlich den Fall, dass sich durch ein neues Datum die Reihenfolge
-- verschiebt.
--
-- ═══ Was nicht geht ═══
--
-- Dieselben Grenzen wie beim Löschen: kein Eintritt, nur Stammdaten- und
-- Vertragsänderungen, nichts Zukünftiges, nichts ohne Feldwerte. Ein Datum
-- in der Zukunft würde aus dem Eintrag eine geplante Änderung machen, und
-- die lebt in pending_org_changes — dorthin führt kein verlässlicher Weg
-- zurück (kein Schlüssel zwischen beiden Tabellen).
create or replace function update_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_datum date;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_alt jsonb;
v_feld text;
v_neuer_wert text;
v_neu jsonb := '[]'::jsonb;
v_korrektur jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_spalte text;
v_typ text;
v_gueltig text;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort berichtigt werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
end if;
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
if v_datum > current_date then
raise exception 'Ein Datum in der Zukunft macht daraus eine geplante Änderung. Dafür ist dieser Weg nicht gedacht.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_alt->>'feld';
select w->>'nachher' into v_neuer_wert
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
where w->>'feld' = v_feld;
if v_neuer_wert is null then
v_neu := v_neu || v_alt;
else
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
end if;
end if;
end loop;
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
raise exception 'Nichts geändert.';
end if;
update employee_history
set changes = v_neu,
event_date = v_datum,
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
where id = v_id;
-- Für jedes betroffene Feld den jüngsten Eintrag suchen, der es trägt, und
-- dessen Nachher setzen. Das schliesst den eben berichtigten Eintrag ein
-- und berücksichtigt ein verschobenes Datum von selbst.
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
if not v_karte ? v_feld then
continue;
end if;
select a->>'nachher' into v_gueltig
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and a->>'feld' = v_feld
order by h.event_date desc, h.created_at desc
limit 1;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
end if;
end loop;
-- Alles in einem UPDATE: chk_weekly_hours koppelt Beschäftigungsausmaß und
-- Wochenstunden, und zwischen zwei getrennten Anweisungen stünde ein
-- Zwischenstand, den die Bedingung verbietet.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
end;
end if;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
case when jsonb_array_length(v_korrektur) > 0
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
end;
$function$;
comment on function update_history_entry(jsonb) is
'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Der heutige Stand wird je Feld aus dem jüngsten Eintrag abgeleitet, der es trägt. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.';
-- Selbstprüfung.
do $$
declare
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if jsonb_typeof(app_feld_karte()) <> 'object' then
raise exception 'app_feld_karte liefert kein Objekt';
end if;
if not (app_feld_karte() ? 'Adresse' and app_feld_karte() ? 'Wochenstunden') then
raise exception 'Die Feldtabelle ist unvollständig';
end if;
if v_del not like '%app_feld_karte()%' then
raise exception 'delete_history_entry nutzt die gemeinsame Feldtabelle nicht';
end if;
if v_del like '%jsonb_build_array(''first_name''%' then
raise exception 'delete_history_entry trägt noch eine eigene Kopie der Feldtabelle';
end if;
if not (select prosecdef from pg_proc where oid = 'public.update_history_entry(jsonb)'::regprocedure) then
raise exception 'update_history_entry muss SECURITY DEFINER sein';
end if;
if v_upd not like '%require_hr_admin%' then
raise exception 'update_history_entry prüft die Berechtigung nicht';
end if;
-- Die Policies bleiben, wie sie sind.
if exists (
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
where c.relname = 'employee_history' and p.polcmd in ('d', 'w')
) then
raise exception 'employee_history hat eine update- oder delete-Policy bekommen';
end if;
end
$$;

View File

@@ -0,0 +1,681 @@
-- Auch geplante Änderungen lassen sich zurücknehmen und berichtigen.
--
-- Bisher endete beides an der Gegenwart: was noch nicht wirksam war, blieb
-- stehen. Der Grund war kein Prinzip, sondern eine fehlende Verbindung — eine
-- noch nicht wirksame Änderung lebt als payload in pending_org_changes, und
-- zwischen ihr und der Historienzeile gab es keinen Schlüssel, nur Person und
-- Datum. Darüber zu raten hätte irgendwann die falsche Zeile getroffen: in
-- den Daten liegen bereits ein Eintritt und eine Vertragsänderung am selben
-- Tag.
--
-- employee_history bekommt deshalb pending_id. change_employee_data setzt es,
-- wenn es eine geplante Änderung anlegt; für alles Wirksame bleibt es null.
--
-- Eine geplante Änderung kann **zwei** Historienzeilen tragen — Stammdaten
-- und Vertrag werden getrennt geführt, hängen aber am selben Vorgang. Deshalb
-- entfernt das Zurücknehmen nur die Felder der betroffenen Gruppe aus dem
-- payload und bricht den Vorgang nur ab, wenn danach nichts übrig bleibt.
--
-- Bestehende Zeilen werden verknüpft, wo es eindeutig ist: genau ein
-- laufender Vorgang der Person am selben Stichtag, den nicht schon eine
-- andere Zeile beansprucht. Alles andere bleibt ohne Bezug — und damit
-- weiterhin unantastbar, mit einer Meldung, die das sagt.
alter table employee_history
add column if not exists pending_id uuid references pending_org_changes(id) on delete set null;
comment on column employee_history.pending_id is
'Der geplante Vorgang, zu dem diese Zeile gehört; null, sobald die Änderung wirksam ist oder es nie einen Vorgang gab. Ohne diesen Bezug lässt sich eine geplante Änderung nicht zurücknehmen — Person und Datum allein sind nicht eindeutig.';
create index if not exists idx_employee_history_pending on employee_history (pending_id) where pending_id is not null;
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- Die Feldtabelle: Beschriftung → Spalte, Typ und Gruppe.
--
-- Die Gruppe ist neu. Eine noch nicht wirksame Änderung liegt als payload in
-- pending_org_changes, und dort sind die Felder nach person / contract / role
-- sortiert — so, wie change_employee_data sie entgegennimmt. Wer eine
-- geplante Änderung berichtigen oder zurücknehmen will, muss wissen, unter
-- welchem Schlüssel ein Feld dort steht.
create or replace function app_feld_karte()
returns jsonb
language sql
immutable
set search_path to 'public', 'pg_temp'
as $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role')
);
$function$;
comment on function app_feld_karte() is
'Beschriftung eines Feldes → [Spalte, Typ, Gruppe im payload]. Quelle für das Zurücksetzen, Berichtigen und Abbrechen von Historieneinträgen.';
-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung.
--
-- Zwei Fälle, und sie sind grundverschieden:
--
-- **Bereits wirksam.** Die Änderung steht in den Stammdaten. Je Feld wird auf
-- den Wert davor zurückgesetzt — aber nur, wenn kein späterer Eintrag
-- dasselbe Feld angefasst hat; sonst gilt der spätere weiter. Das ist „die
-- letztgültige Änderung ist die schlagende".
--
-- **Noch nicht wirksam.** Es gibt nichts zurückzusetzen; die Änderung wartet
-- als payload in pending_org_changes. Zurückgenommen wird sie, indem ihre
-- Felder aus dem payload verschwinden. Bleibt danach nichts übrig, wird die
-- geplante Änderung abgebrochen — bleibt etwas, läuft sie mit dem Rest.
--
-- Der zweite Fall braucht einen verlässlichen Bezug zwischen Historienzeile
-- und geplanter Änderung. Den gab es nicht, und über Person und Datum zu
-- raten hätte irgendwann die falsche Zeile getroffen: in den Daten liegen
-- bereits ein Eintritt und eine Vertragsänderung am selben Tag. Deshalb
-- trägt employee_history jetzt pending_id.
--
-- Eine geplante Änderung kann **zwei** Historienzeilen haben — Stammdaten und
-- Vertrag werden getrennt geführt. Deshalb wird immer nur die Gruppe des
-- betroffenen Eintrags entfernt, nie der ganze payload.
create or replace function delete_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_gruppe text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_leer boolean;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- ── Noch nicht wirksam: die geplante Änderung entschärfen ──────────
if v_eintrag.event_date > current_date then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := v_plan.payload;
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_gruppe := v_karte->v_feld->>2;
if v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte);
end if;
end loop;
v_leer := coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0;
if v_leer then
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
else
update pending_org_changes set payload = v_neuer_payload where id = v_plan.id;
end if;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) ||
case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end,
v_eintrag.changes);
return;
end if;
-- ── Bereits wirksam: Feld für Feld zurücksetzen ────────────────────
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
comment on function delete_history_entry(jsonb) is
'Nimmt eine Stammdaten- oder Vertragsänderung zurück. Bereits wirksam: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat. Noch nicht wirksam: entfernt die Felder aus dem geplanten Vorgang und bricht ihn ab, wenn nichts übrig bleibt. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.';
-- Einen Historieneintrag berichtigen.
--
-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier
-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert
-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu
-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung,
-- von denen der erste nie stattgefunden hat.
--
-- Geändert wird das **Nachher** und das **Datum**. Das Vorher bleibt: es
-- beschreibt, was vor der Änderung galt, und das lässt sich nachträglich
-- nicht anders beschliessen.
--
-- Bereits wirksam: die Stammdaten werden nachgezogen, wobei je Feld der
-- jüngste Eintrag gewinnt, der es trägt — dieselbe Regel wie beim Löschen,
-- von der anderen Seite gelesen, und sie trägt zusätzlich den Fall, dass ein
-- neues Datum die Reihenfolge verschiebt.
--
-- Noch nicht wirksam: geändert wird der payload des geplanten Vorgangs und
-- sein Stichtag. An den Stammdaten passiert nichts — dort steht die Änderung
-- ja noch nicht.
create or replace function update_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_datum date;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_alt jsonb;
v_feld text;
v_neuer_wert text;
v_neu jsonb := '[]'::jsonb;
v_korrektur jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_spalte text;
v_typ text;
v_gruppe text;
v_gueltig text;
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_war_zukunft boolean;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
end if;
v_war_zukunft := v_eintrag.event_date > current_date;
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
-- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben
-- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder
-- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst
-- werden, und dafür gibt es die fachlichen Vorgänge.
if v_war_zukunft and v_datum <= current_date then
raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.';
end if;
if not v_war_zukunft and v_datum > current_date then
raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_alt->>'feld';
select w->>'nachher' into v_neuer_wert
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
where w->>'feld' = v_feld;
if v_neuer_wert is null then
v_neu := v_neu || v_alt;
else
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
end if;
end if;
end loop;
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
raise exception 'Nichts geändert.';
end if;
update employee_history
set changes = v_neu,
event_date = v_datum,
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
where id = v_id;
-- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ───────────
if v_war_zukunft then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht berichtigen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text));
for v_alt in select * from jsonb_array_elements(v_neu) loop
v_feld := v_alt->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_gruppe := v_karte->v_feld->>2;
if not v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb);
end if;
v_neuer_payload := jsonb_set(
v_neuer_payload,
array[v_gruppe, v_spalte],
case
when v_alt->>'nachher' is null then 'null'::jsonb
when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', '))
when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean)
when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric)
else to_jsonb(v_alt->>'nachher')
end,
true);
end loop;
update pending_org_changes
set payload = v_neuer_payload, effective_date = v_datum
where id = v_plan.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end ||
case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
return;
end if;
-- ── Bereits wirksam: Stammdaten nachziehen ─────────────────────────
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
if not v_karte ? v_feld then
continue;
end if;
select a->>'nachher' into v_gueltig
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and a->>'feld' = v_feld
and h.event_date <= current_date
order by h.event_date desc, h.created_at desc
limit 1;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
end if;
end loop;
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
end;
end if;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
case when jsonb_array_length(v_korrektur) > 0
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
end;
$function$;
comment on function update_history_entry(jsonb) is
'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Bereits wirksam: die Stammdaten werden je Feld aus dem jüngsten wirksamen Eintrag abgeleitet. Noch nicht wirksam: payload und Stichtag des geplanten Vorgangs werden nachgezogen. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.';
-- Bestehende Zeilen verknüpfen, wo genau ein Vorgang in Frage kommt.
with kandidat as (
select h.id as history_id,
(select p.id
from pending_org_changes p
where p.employee_id = h.employee_id
and p.effective_date = h.event_date
and p.status = 'pending'
and p.change_type = 'contract_change'
and not exists (
select 1 from employee_history x
where x.pending_id = p.id and x.event_type = h.event_type
)
limit 2) as plan_id,
(select count(*)
from pending_org_changes p
where p.employee_id = h.employee_id
and p.effective_date = h.event_date
and p.status = 'pending'
and p.change_type = 'contract_change') as anzahl
from employee_history h
where h.event_date > current_date
and h.pending_id is null
and h.event_type in ('Stammdatenänderung', 'Vertragsänderung')
)
update employee_history h
set pending_id = k.plan_id
from kandidat k
where h.id = k.history_id
and k.anzahl = 1
and k.plan_id is not null;
-- Selbstprüfung.
do $$
declare
v_ced text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employee_history' and column_name = 'pending_id'
) then
raise exception 'employee_history.pending_id fehlt';
end if;
if (length(v_ced) - length(replace(v_ced, 'v_pending_id)', ''))) / length('v_pending_id)') <> 2 then
raise exception 'change_employee_data schreibt pending_id nicht in beide Historien-Einträge';
end if;
if v_ced not like '%returning id into v_pending_id%' then
raise exception 'change_employee_data merkt sich den angelegten Vorgang nicht';
end if;
if jsonb_array_length(app_feld_karte()->'Adresse') <> 3 then
raise exception 'Die Feldtabelle nennt die Gruppe nicht';
end if;
if app_feld_karte()->'Adresse'->>2 <> 'person' or app_feld_karte()->'Wochenstunden'->>2 <> 'contract' then
raise exception 'Die Gruppen in der Feldtabelle stimmen nicht';
end if;
if v_del not like '%pending_id is null%' or v_upd not like '%pending_id is null%' then
raise exception 'Der Zukunftsfall wird nicht behandelt';
end if;
-- Die Policies bleiben, wie sie sind.
if exists (
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
where c.relname = 'employee_history' and p.polcmd in ('d', 'w')
) then
raise exception 'employee_history hat eine update- oder delete-Policy bekommen';
end if;
end
$$;

View File

@@ -0,0 +1,113 @@
-- Wer nie angetreten ist: Austrittsgrund „No Show".
--
-- Der Fall gibt es, und bisher liess er sich nicht erfassen. Ein Austritt am
-- Eintrittstag scheiterte an chk_assignment_range: die Besetzung wurde auf
-- valid_to = valid_from geschlossen, und ein leeres Intervall ist dort
-- verboten. Ausweichen auf den Folgetag hätte bedeutet, einen Tag
-- Beschäftigung zu behaupten, den es nie gab — mit allem, was daran hängt:
-- Kopfzahl, Zugehörigkeit, Auswertungen zum Stichtag.
--
-- Drei Dinge macht dieser Grund deshalb anders:
--
-- * Das Austrittsdatum ist **immer** der Eintrittstag, unabhängig davon,
-- was übergeben wurde. Daraus folgt „nie aktiv" von selbst: als
-- beschäftigt gilt, wessen exit_date *nach* dem Stichtag liegt, und das
-- ist hier an keinem Tag der Fall.
-- * Die Planstellenzuordnung wird **entfernt**, nicht geschlossen. Die
-- Stelle war nie besetzt und ist wieder frei.
-- * Der Status springt sofort auf „Ausgetreten", auch bei einem Eintritt in
-- der Zukunft. Sonst bliebe in der Spalte „Geplant" stehen — es gibt
-- keinen Lauf, der das später nachzieht.
--
-- Die Bedingung unten hält das fest, egal auf welchem Weg jemand schreibt —
-- auch über den Import.
alter table employees drop constraint if exists chk_no_show_am_eintritt;
alter table employees add constraint chk_no_show_am_eintritt
check (exit_reason is distinct from 'No Show' or exit_date = entry_date);
comment on constraint chk_no_show_am_eintritt on employees is
'Ein Nichtantritt endet am Eintrittstag. Sonst gäbe es Tage, an denen die Person als beschäftigt zählte, obwohl sie nie da war. „is distinct from" statt „<>", damit ein leerer Grund nicht zu null auswertet und die Bedingung durchrutschen lässt.';
CREATE OR REPLACE FUNCTION public.terminate_employee(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_exit date := (payload->>'exit_date')::date;
v_name text;
-- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie
-- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten.
v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show';
v_entry date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, entry_date into v_name, v_entry
from employees where id = v_employee_id;
-- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus.
-- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war:
-- die Statusableitung verlangt exit_date > Stichtag, um jemanden als
-- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist
-- damit keine zusätzliche Regel, sondern folgt aus dem Datum.
if v_no_show then
v_exit := v_entry;
end if;
update employees set
-- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der
-- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen,
-- denn es gibt keinen Lauf, der sie später nachzieht.
status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
if v_no_show then
-- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu
-- schliessen ginge nicht — chk_assignment_range verlangt ein echtes
-- Intervall, und genau daran scheiterte ein Austritt am Eintrittstag
-- bisher. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder
-- frei, und es steht nirgends, jemand hätte sie je innegehabt.
delete from position_assignments
where employee_id = v_employee_id and valid_to is null;
else
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt',
case when v_no_show
then 'Kein Antritt am ' || v_entry || ' (No Show)'
else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id, case when v_no_show then 'Kein Antritt am ' || v_entry || ' (No Show)' else 'Austritt am ' || v_exit end);
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_def text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure);
begin
if v_def not like '%v_no_show%' then
raise exception 'terminate_employee kennt den Nichtantritt nicht';
end if;
if v_def not like '%delete from position_assignments%' then
raise exception 'Die Zuordnung wird bei einem Nichtantritt nicht entfernt';
end if;
if not exists (
select 1 from pg_constraint where conname = 'chk_no_show_am_eintritt'
) then
raise exception 'Die Bedingung fehlt';
end if;
end
$$;

View File

@@ -0,0 +1,357 @@
-- Besonderer Kündigungsschutz.
--
-- Betriebsratsmitglieder, Schwangere, Eltern in Karenz, begünstigte
-- Behinderte, Lehrlinge, Präsenzdiener — für sie gelten eigene Regeln, bevor
-- ein Dienstverhältnis beendet werden darf. Das Werkzeug entscheidet das
-- nicht, aber es soll niemanden einen Austritt erfassen lassen, ohne es zu
-- erwähnen.
--
-- Zwei Spalten, wie beim Dienstwagen: ein Kennzeichen und eine Angabe, die
-- nur mit ihm zusammen Sinn ergibt. Das Datum ist **freiwillig** — bei einem
-- Betriebsratsmandat steht das Ende oft fest, bei einer Schwangerschaft
-- nicht, und ein Pflichtfeld zwänge dann zu einer erfundenen Zahl.
--
-- Die Bedingung sagt nur, was ohne das Kennzeichen nicht sein darf. Sie
-- verlangt umgekehrt kein Datum.
alter table employees
add column if not exists has_kuendigungsschutz boolean not null default false,
add column if not exists kuendigungsschutz_bis date;
comment on column employees.has_kuendigungsschutz is
'Besonderer Kündigungsschutz — Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehrverhältnis. Löst beim Austritt eine Warnung aus.';
comment on column employees.kuendigungsschutz_bis is
'Ende des Schutzes, falls bekannt. Freiwillig: bei einer Schwangerschaft steht es nicht fest, bei einem Mandat schon.';
alter table employees drop constraint if exists chk_kuendigungsschutz_bis;
alter table employees add constraint chk_kuendigungsschutz_bis
check (has_kuendigungsschutz or kuendigungsschutz_bis is null);
comment on constraint chk_kuendigungsschutz_bis on employees is
'Ein Enddatum ohne Schutz wäre ein Rest, den niemand mehr deuten kann. has_kuendigungsschutz ist NOT NULL, deshalb genügt hier die einfache Oder-Form — anders als bei chk_dienstwagen_art, wo eine nullbare Spalte die Bedingung sonst durchrutschen liesse.';
CREATE OR REPLACE FUNCTION public.app_feld_karte()
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path TO 'public', 'pg_temp'
AS $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role')
);
$function$;
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
RETURNS uuid
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
raise exception 'Es muss eine Personalnummer angegeben werden.';
end if;
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
end if;
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_entry < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
end if;
if v_bis is not null and v_entry >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_entry);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
has_kuendigungsschutz, kuendigungsschutz_bis,
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
)
values (
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false),
coalesce((payload->>'has_kuendigungsschutz')::boolean, false),
-- Das Datum nur, wenn der Schutz überhaupt gesetzt ist: sonst bliebe
-- ein Enddatum ohne Schutz stehen, und chk_kuendigungsschutz_bis
-- würde es zu Recht abweisen.
case when coalesce((payload->>'has_kuendigungsschutz')::boolean, false)
then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
nullif(payload->>'emergency_contact_name', ''),
nullif(payload->>'emergency_contact_phone', ''),
nullif(payload->>'emergency_contact_relation', '')
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$function$;
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure);
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if v_hire not like '%has_kuendigungsschutz%' then
raise exception 'hire_employee nimmt den Kündigungsschutz nicht entgegen';
end if;
if v_chg not like '%Besonderer Kündigungsschutz%' then
raise exception 'change_employee_data protokolliert den Kündigungsschutz nicht';
end if;
if not (app_feld_karte() ? 'Besonderer Kündigungsschutz' and app_feld_karte() ? 'Kündigungsschutz bis') then
raise exception 'Die Feldtabelle kennt den Kündigungsschutz nicht — dann liesse sich ein Eintrag dazu nicht berichtigen';
end if;
if app_feld_karte()->'Kündigungsschutz bis'->>2 <> 'role' then
raise exception 'Die Gruppe im payload stimmt nicht';
end if;
end
$$;

View File

@@ -0,0 +1,268 @@
-- Teilzeiten sind keine Abwesenheiten.
--
-- Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit und Wiedereingliederungs-
-- teilzeit standen in der Liste der Langzeitabwesenheiten. Wer so erfasst
-- wurde, galt als abwesend: die Person verschwand aus dem Bestand, ihre
-- Berichtslinie fiel an eine Vertretung, und in Auswertungen zählte sie nicht
-- mehr mit — obwohl sie jede Woche im Haus war, nur kürzer.
--
-- Sie wandern deshalb dorthin, wo sie hingehören:
--
-- * **Wiedereingliederungs- und Elternteilzeit** an die Rückkehr aus einer
-- Abwesenheit. Beide beginnen typischerweise genau dann, wenn die
-- Abwesenheit endet, und beide sind der Grund dafür, dass jemand mit
-- weniger Stunden zurückkommt.
-- * **Bildungs- und Pflegeteilzeit** an die Stundenänderung unter „Daten
-- ändern", neben der gewöhnlichen vertraglichen Änderung.
--
-- Der Grund wird **mit der Änderung** festgehalten, nicht als Zustand an der
-- Person. Ein Zustand müsste gepflegt werden — es gibt aber niemanden, der
-- nachträgt, wann eine Bildungsteilzeit endet, und ein Feld, das schleichend
-- veraltet, ist schlimmer als keines. In der Historie steht der Grund dort,
-- wo auch der geänderte Wert steht, und bleibt dort dauerhaft lesbar.
--
-- **Die Prüfbedingung auf absence_type bleibt unverändert.** Drei Personen
-- tragen die Werte gerade (Pflegeteilzeit, Wiedereingliederungsteilzeit); sie
-- zu verbieten hiesse, bestehende Zeilen ungültig zu machen. Aus der Auswahl
-- verschwinden sie, die Geschichte bleibt lesbar.
CREATE OR REPLACE FUNCTION public.record_karenz_return(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
-- Warum sich die Stunden ändern. Kein Feld an der Person, sondern eine
-- Eigenschaft *dieser* Änderung — es gibt niemanden, der später
-- nachträgt, wann eine Bildungsteilzeit endet. In der Historie steht
-- der Grund damit dort, wo auch der Wert steht.
v_stunden_grund text := nullif(payload->>'hours_reason', '');
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date
|| case when v_stunden_grund is not null and v_contract ? 'weekly_hours'
then ' — ' || v_stunden_grund else '' end,
v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if v_ret not like '%reduction_reason%' then
raise exception 'record_karenz_return nimmt den Grund nicht entgegen';
end if;
if v_chg not like '%hours_reason%' then
raise exception 'change_employee_data nimmt den Grund der Stundenänderung nicht entgegen';
end if;
end
$$;

View File

@@ -0,0 +1,350 @@
-- Die Teilzeitvariante als Zustand, nicht nur als Notiz.
--
-- Beim letzten Schritt sind die vier Teilzeiten aus der Abwesenheitsliste
-- gewandert und wurden mit der Änderung festgehalten — im Beschreibungstext
-- der Historie. Damit liess sich nachlesen, *dass* jemand in Bildungsteilzeit
-- ging, aber nicht auswerten, wer gerade in einer ist, und am Profil stand es
-- nirgends.
--
-- Also ein richtiges Feld: teilzeit_art, dazu ein freiwilliges Enddatum.
--
-- Der Einwand von damals — ein Zustand veraltet, weil niemand nachträgt, wann
-- eine Bildungsteilzeit endet — bleibt richtig und ist der Grund für
-- teilzeit_bis. Mit einem Enddatum kann eine Auswertung selbst entscheiden,
-- was noch läuft, statt sich auf gepflegte Daten zu verlassen. Bleibt das
-- Datum leer, heisst das „Ende offen", und das ist eine ehrliche Aussage.
--
-- Geführt wird das Feld über den gewöhnlichen Weg der Änderungen: es steht in
-- app_feld_karte, taucht in der Historie als Feld mit Vorher/Nachher auf und
-- lässt sich dort berichtigen wie jedes andere. Der Anhang am
-- Beschreibungstext aus dem letzten Schritt entfällt dafür — zweimal
-- dasselbe zu schreiben lädt nur dazu ein, dass die zwei Fassungen
-- auseinanderlaufen.
alter table employees
add column if not exists teilzeit_art text,
add column if not exists teilzeit_bis date;
comment on column employees.teilzeit_art is
'Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen vertraglichen Stundenregelung. Keine Abwesenheit — die Person arbeitet, nur kürzer.';
comment on column employees.teilzeit_bis is
'Ende der Teilzeit, falls bekannt. Freiwillig; leer heisst „Ende offen". Erlaubt Auswertungen darüber, was noch läuft, ohne auf nachgepflegte Daten angewiesen zu sein.';
alter table employees drop constraint if exists chk_teilzeit_art;
alter table employees add constraint chk_teilzeit_art
check (teilzeit_art is null or teilzeit_art in
('Bildungsteilzeit', 'Elternteilzeit', 'Pflegeteilzeit', 'Wiedereingliederungsteilzeit'));
alter table employees drop constraint if exists chk_teilzeit_bis;
alter table employees add constraint chk_teilzeit_bis
check (teilzeit_bis is null or teilzeit_art is not null);
comment on constraint chk_teilzeit_bis on employees is
'Ein Enddatum ohne Variante wäre ein Rest ohne Bezug. Umgekehrt ist eine Variante ohne Enddatum ausdrücklich erlaubt — nicht jede Teilzeit hat ein bekanntes Ende.';
create index if not exists idx_employees_teilzeit on employees (teilzeit_art) where teilzeit_art is not null;
CREATE OR REPLACE FUNCTION public.app_feld_karte()
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path TO 'public', 'pg_temp'
AS $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role')
);
$function$;
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
-- Die Teilzeitvariante ist jetzt ein Feld an der Person (teilzeit_art)
-- und läuft über die gewöhnliche Änderungsliste. Der frühere Anhang am
-- Beschreibungstext ist damit weg — zweimal dasselbe zu schreiben lädt
-- nur dazu ein, dass die zwei Fassungen auseinanderlaufen.
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
CREATE OR REPLACE FUNCTION public.record_karenz_return(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
begin
if not (app_feld_karte() ? 'Teilzeitvariante' and app_feld_karte() ? 'Teilzeit bis') then
raise exception 'Die Feldtabelle kennt die Teilzeitvariante nicht';
end if;
if v_chg not like '%teilzeit_art%' then
raise exception 'change_employee_data schreibt die Teilzeitvariante nicht';
end if;
if v_chg like '%hours_reason%' then
raise exception 'Der alte Anhang am Beschreibungstext steht noch drin';
end if;
if v_ret not like '%teilzeit_art%' then
raise exception 'record_karenz_return schreibt die Teilzeitvariante nicht';
end if;
end
$$;

View File

@@ -0,0 +1,119 @@
-- Die Teilzeitvariante übersteht auch eine geplante Rückkehr.
--
-- record_karenz_return legt für ein Rückkehrdatum in der Zukunft eine Zeile
-- in pending_org_changes an; der Tageslauf wendet sie an. Diese Zeile trug
-- bisher nur Beschäftigungsausmaß und Stunden — die Variante wäre am Stichtag
-- verlorengegangen, und jemand käme in Elternteilzeit zurück, ohne dass es
-- irgendwo stünde ausser im Beschreibungstext der Historie.
--
-- Aufgefallen beim Proben: die Probe hatte ein Rückkehrdatum in der Zukunft
-- gewählt und lief deshalb in genau diesen Zweig.
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden. Der Nachweis stünde nur in der Historie,
-- auswerten liesse sich nichts.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_apply text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
begin
if v_apply not like '%teilzeit_art%' then
raise exception 'Der Tageslauf überträgt die Teilzeitvariante nicht';
end if;
if v_ret not like '%teilzeit_art%' then
raise exception 'record_karenz_return legt die Variante nicht in den Vorgang';
end if;
end
$$;

Some files were not shown because too many files have changed in this diff Show More