Give exits their own checklist, next to the entry one
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m42s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m35s

The offboarding list was a checkbox fieldset inside the exit panel — four
items, never sent anywhere. Nothing in terminateEmployee's payload
carried them; ticking a box there recorded exactly nothing.

It's replaced with the same kind of list the entries got: its own tab,
appearing the moment an exit is recorded, with one item per row, a
comment on each, and — unlike the fieldset — a record of who touched it
and when.

The eleven items come from the same printed sheet as the entry list.
Nine are plain checkboxes. Two are text fields under "Vermerke":
remaining vacation and the balance transferred for payout — the sheet
names "Überleitung Salden für Auszahlung" twice, once as a task to do
and once as the actual figure, and those are genuinely two different
questions, kept as two items. Where the sheet still says "GKK" rather
than today's "ÖGK", it's left as written — that's the name the process
runs under internally, not a typo.

No Show gets no list. Never having worked a single day, there's no IT
access to revoke, no GKK registration to undo, no Dienstzettel to
collect — an empty checklist there would be a label with nothing behind
it. Both the tab and the auto-creation on exit check for this
specifically, not just the "Ausgetreten" status that No Show shares with
a real exit. Rehiring the same person hides the tab again — the data
stays, since it happened, but a checklist for someone currently working
has nothing to point at.

The engine (what counts as done, how progress is computed) moved into
lib/checklist.ts so onboarding and offboarding can't drift into two
different ideas of "done" the way two independent copies eventually do;
lib/onboarding.ts and lib/offboarding.ts bind it to their own item list,
and the tab UI is a single ChecklistPanel bound the same way.

Checked against the real database: a real exit creates all eleven items
in the same transaction as the exit itself; a No Show creates none;
rehiring flips the tab off while the old answers stay queryable. One
false alarm during that check turned out to be the user's own clicks on
a real employee's onboarding list, made in the browser while trying the
earlier feature — left untouched, not test debris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 16:25:58 +02:00
parent 82d07f0d95
commit f85731dde5
15 changed files with 894 additions and 339 deletions

View File

@@ -0,0 +1,148 @@
-- Offboarding-Checkliste je Person
--
-- Dieselbe Sache wie onboarding_tasks (20260817100000), für den anderen Weg:
-- je Person und Punkt eine Zeile, der Schlüssel als Text statt als Fremd-
-- schlüssel, damit ein später gestrichener Punkt seine alten Antworten
-- stehen lässt statt sie mitzureissen. Die Begründung im Einzelnen steht bei
-- der Onboarding-Migration; hier nur, was abweicht.
--
-- Eine eigene Tabelle statt einer gemeinsamen mit Spalte „Richtung": beide
-- Listen haben eigene Punkte (lib/onboarding.ts, lib/offboarding.ts), einen
-- eigenen Anlass (Ein-, Aus- oder Wiedereintritt) und eigene Protokollzeilen.
-- Eine gemeinsame Tabelle würde das mit einer Fallunterscheidung nachbauen,
-- die schon in zwei Tabellen und zwei Funktionen so nicht existiert.
create table if not exists offboarding_tasks (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
item_key text not null,
erledigt boolean not null default false,
wert text,
kommentar text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
updated_by uuid references profiles(id) on delete set null,
updated_by_name text,
constraint offboarding_tasks_eine_zeile unique (employee_id, item_key)
);
create index if not exists offboarding_tasks_employee_id_idx on offboarding_tasks (employee_id);
create index if not exists offboarding_tasks_offen_idx on offboarding_tasks (employee_id) where not erledigt;
drop policy if exists offboarding_tasks_hr_all on offboarding_tasks;
create policy offboarding_tasks_hr_all on offboarding_tasks
for all using (is_hr_user()) with check (is_hr_user());
-- ── Einen Punkt festhalten ─────────────────────────────────────────────
-- Wortgleich mit set_onboarding_task, nur auf die andere Tabelle bezogen.
create or replace function set_offboarding_task(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_item_key text := nullif(trim(payload->>'item_key'), '');
v_name text;
v_vorher offboarding_tasks%rowtype;
v_erledigt boolean;
v_wert text;
v_kommentar text;
begin
perform require_hr_admin();
if v_item_key is null then
raise exception 'Es wurde kein Punkt angegeben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
select * into v_vorher from offboarding_tasks
where employee_id = v_employee_id and item_key = v_item_key for update;
v_erledigt := case when payload ? 'erledigt' then (payload->>'erledigt')::boolean
else coalesce(v_vorher.erledigt, false) end;
v_wert := case when payload ? 'wert' then nullif(trim(payload->>'wert'), '') else v_vorher.wert end;
v_kommentar := case when payload ? 'kommentar' then nullif(trim(payload->>'kommentar'), '')
else v_vorher.kommentar end;
insert into offboarding_tasks (employee_id, item_key, erledigt, wert, kommentar, updated_by, updated_by_name)
values (v_employee_id, v_item_key, v_erledigt, v_wert, v_kommentar,
app_current_user_id(), current_actor_name())
on conflict (employee_id, item_key) do update
set erledigt = excluded.erledigt,
wert = excluded.wert,
kommentar = excluded.kommentar,
updated_at = now(),
updated_by = excluded.updated_by,
updated_by_name = excluded.updated_by_name;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Offboarding-Punkt', v_name, v_employee_id,
v_item_key || ': ' ||
case when v_wert is not null then v_wert
when v_erledigt then 'erledigt'
else 'offen' end ||
coalesce(' — ' || v_kommentar, ''));
end;
$function$;
revoke all on function set_offboarding_task(jsonb) from public;
grant execute on function set_offboarding_task(jsonb) to public;
-- ── Die Liste anlegen ──────────────────────────────────────────────────
--
-- Anders als beim Onboarding steht dieser Aufruf nicht für jeden Austritt in
-- derselben Transaktion, die den Status setzt: bei einem No Show — wer nie
-- angetreten ist — gibt es nichts offzuboarden, es war nie ein IT-Zugang
-- eingerichtet, keine GKK-Anmeldung, kein Dienstzettel. Diese Funktion prüft
-- das nicht selbst; die aufrufende Seite (actions/employees.ts) entscheidet,
-- wann sie gerufen wird. Sie existiert trotzdem als eigene, aufrufbare
-- Funktion — für den Nachtrag bei einem Austritt von vor dieser Liste.
create or replace function start_offboarding(payload jsonb)
returns integer
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_keys jsonb := coalesce(payload->'item_keys', '[]'::jsonb);
v_name text;
v_angelegt integer;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
if jsonb_array_length(v_keys) = 0 then
raise exception 'Es wurden keine Punkte übergeben.';
end if;
insert into offboarding_tasks (employee_id, item_key, updated_by, updated_by_name)
select v_employee_id, k, app_current_user_id(), current_actor_name()
from jsonb_array_elements_text(v_keys) k
on conflict (employee_id, item_key) do nothing;
get diagnostics v_angelegt = row_count;
if v_angelegt > 0 then
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Offboarding-Checkliste angelegt', v_name, v_employee_id,
v_angelegt || ' Punkte');
end if;
return v_angelegt;
end;
$function$;
revoke all on function start_offboarding(jsonb) from public;
grant execute on function start_offboarding(jsonb) to public;