Put the onboarding checklist where the file is
The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.
Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:
Haken — the normal case. The Meldezettel is there or it is not.
Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
not decoration: "nein" is a finding, "not asked yet" is not.
One checkbox cannot say both.
Text — shoe, shirt and trouser size. The value is the point;
ticked off it would be worthless.
Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.
Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.
The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.
A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.
Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
171
supabase/migrations/20260817100000_onboarding_checkliste.sql
Normal file
171
supabase/migrations/20260817100000_onboarding_checkliste.sql
Normal file
@@ -0,0 +1,171 @@
|
||||
-- Onboarding-Checkliste je Person
|
||||
--
|
||||
-- Bisher lief das über ein Blatt neben der Anwendung: eine Tabelle mit
|
||||
-- Kästchen, je Eintritt einmal ausgedruckt. Was darauf steht, weiss dann nur,
|
||||
-- wer das Blatt hat — und ob der Meldezettel schon da ist, lässt sich weder
|
||||
-- suchen noch auswerten noch vertreten.
|
||||
--
|
||||
-- Gespeichert wird **je Person und Punkt eine Zeile**, unter dem Schlüssel des
|
||||
-- Punktes. Die Punkte selbst stehen in lib/onboarding.ts, nicht hier: eine
|
||||
-- Checkliste ist ein Firmenprozess und kein Stammdatum. Der Schlüssel als Text
|
||||
-- statt als Fremdschlüssel hat einen Preis (die Datenbank kennt die gültigen
|
||||
-- Werte nicht) und einen Grund: ein später gestrichener Punkt lässt die alten
|
||||
-- Antworten stehen, statt sie mitzureissen. Eine Akte von damals bleibt so
|
||||
-- lesbar, auch wenn die Liste heute anders aussieht.
|
||||
--
|
||||
-- Drei Arten von Antwort in einer Tabelle, weil es dieselbe Sache ist:
|
||||
-- • Haken → erledigt
|
||||
-- • Ja/Nein → wert ('ja'/'nein'); offen ist etwas anderes als nein
|
||||
-- • Text → wert (Grössen)
|
||||
-- `erledigt` trägt bei allen dreien die Frage „abgehakt?", damit der
|
||||
-- Fortschritt eine Spalte hat und keine Fallunterscheidung.
|
||||
|
||||
create table if not exists onboarding_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,
|
||||
-- Der Name mitgeschrieben, nicht nur die Kennung: wer eine Checkliste von
|
||||
-- vor zwei Jahren aufschlägt, will lesen, wer abgehakt hat, auch wenn die
|
||||
-- Person längst nicht mehr im Verzeichnis steht.
|
||||
updated_by_name text,
|
||||
constraint onboarding_tasks_eine_zeile unique (employee_id, item_key)
|
||||
);
|
||||
|
||||
create index if not exists onboarding_tasks_employee_id_idx on onboarding_tasks (employee_id);
|
||||
-- Für „was ist noch offen": der Teilindex liest nur die unerledigten.
|
||||
create index if not exists onboarding_tasks_offen_idx on onboarding_tasks (employee_id) where not erledigt;
|
||||
|
||||
drop policy if exists onboarding_tasks_hr_all on onboarding_tasks;
|
||||
create policy onboarding_tasks_hr_all on onboarding_tasks
|
||||
for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
|
||||
-- ── Einen Punkt festhalten ─────────────────────────────────────────────
|
||||
--
|
||||
-- Ein Aufruf für alle drei Arten: gesetzt wird, was mitkommt. Wer nur den
|
||||
-- Kommentar ändert, schickt nur den Kommentar — sonst würde ein Tippfehler im
|
||||
-- Kommentarfeld den Haken mitlöschen.
|
||||
create or replace function set_onboarding_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 onboarding_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 onboarding_tasks
|
||||
where employee_id = v_employee_id and item_key = v_item_key for update;
|
||||
|
||||
-- Weggelassen heisst „unverändert", nicht „leeren". Der Unterschied ist der
|
||||
-- Grund, warum hier `payload ? 'feld'` steht und nicht coalesce: eine
|
||||
-- ausdrückliche null muss löschen können.
|
||||
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 onboarding_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;
|
||||
|
||||
-- Kein Eintrag in employee_history: das ist keine Änderung an der Person,
|
||||
-- sondern der Stand einer Aufgabe. In der Historie stünden sonst
|
||||
-- fünfundzwanzig Zeilen zwischen Eintritt und Versetzung. Ins Protokoll
|
||||
-- gehört es trotzdem — es ist eine Handlung mit Urheber.
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Onboarding-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_onboarding_task(jsonb) from public;
|
||||
grant execute on function set_onboarding_task(jsonb) to public;
|
||||
|
||||
|
||||
-- ── Die Liste anlegen ──────────────────────────────────────────────────
|
||||
--
|
||||
-- Eine Checkliste entsteht mit dem Eintritt. Dass sie *existiert*, ist die
|
||||
-- Aussage „diese Person ist im Onboarding" — deshalb wird sie angelegt und
|
||||
-- nicht bloss beim ersten Klick nebenbei erzeugt: eine leere Liste, auf der
|
||||
-- noch nichts steht, ist der eigentliche Anfangszustand und muss sichtbar
|
||||
-- sein.
|
||||
--
|
||||
-- Die Punkte kommen aus dem Aufruf, nicht aus der Datenbank: sie stehen in
|
||||
-- lib/onboarding.ts, und zwei Listen nebeneinander liefen auseinander.
|
||||
create or replace function start_onboarding(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 onboarding_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(), 'Onboarding-Checkliste angelegt', v_name, v_employee_id,
|
||||
v_angelegt || ' Punkte');
|
||||
end if;
|
||||
|
||||
return v_angelegt;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
revoke all on function start_onboarding(jsonb) from public;
|
||||
grant execute on function start_onboarding(jsonb) to public;
|
||||
Reference in New Issue
Block a user