Rename Karenz to Langzeitabwesenheit and record its type

Karenz was doing duty as the name for every kind of extended absence, but
the cases behave differently in payroll and reporting — Wochenhilfe, a
Präsenzdienst, a long sick leave and a sabbatical are not the same thing.
The concept is now called Langzeitabwesenheit and carries which kind it is.

- employees.absence_type, constrained to the thirteen kinds. start_karenz
  stores it on both paths (written straight away, or parked in the
  pending_org_changes payload when the absence starts later);
  record_karenz_return and the karenz_return branch of
  apply_due_pending_changes clear it, so a returned employee does not keep
  looking like they are still away. It also reaches employee_history, the
  audit log and the employee export.
- The status enum value stays 'Karenz'. Postgres can rename an enum value in
  place, but every stored function body that spells it would then reference
  a value that no longer exists — a dozen functions across fifteen
  migrations, rewritten for a label. The mapping lives in lib/absence.ts
  instead, which is the single place the UI reads the display name from.
- Where a kind is recorded the chip shows it — "Bildungskarenz" says more
  than "Langzeitabwesenheit". Absences predating the field have none and
  fall back to the generic name rather than to a guess, and a value outside
  the list is dropped rather than echoed into the UI.
- The export prints the display name, not the raw enum: a payroll hand-off
  reading "Karenz" for what the app calls Langzeitabwesenheit only causes
  questions. Audit filter options keep their stored values and change only
  their labels.
- The seed spreads the twelve absences across the kinds; all of them being
  Karenz would leave any breakdown by kind invisible.
This commit is contained in:
2026-07-25 14:34:28 +02:00
parent 37bb107cd4
commit 8282d7f581
16 changed files with 471 additions and 45 deletions

View File

@@ -0,0 +1,266 @@
-- Long-term absence gets a type.
--
-- "Karenz" was being used as the name for every kind of extended absence,
-- but the cases behave differently in payroll and reporting: Wochenhilfe,
-- Präsenz-/Zivildienst, a long sick leave and a sabbatical are not the same
-- thing. The UI now calls the concept "Langzeitabwesenheit" and asks which
-- kind it is.
--
-- Deliberately NOT renaming the 'Karenz' enum value in employment_status or
-- history_event_type. Postgres can rename an enum value in place, but every
-- stored function body that spells 'Karenz' would then reference a value
-- that no longer exists — that is a dozen functions across fifteen
-- migrations, all of them rewritten for a label change. The name is a
-- presentation concern, so it is mapped to "Langzeitabwesenheit" in one
-- place in the UI (lib/absence.ts) and the stored value stays put.
alter table employees add column if not exists absence_type text;
alter table employees drop constraint if exists chk_absence_type;
alter table employees add constraint chk_absence_type check (
absence_type is null or absence_type in (
'Wochenhilfe (Mutterschutz)',
'Elternkarenz (inkl. Väterkarenz)',
'Papamonat',
'Bildungskarenz',
'Bildungsteilzeit',
'Präsenzdienst',
'Zivildienst',
'Langer Krankenstand',
'Wiedereingliederungsteilzeit',
'Pflegekarenz',
'Pflegeteilzeit',
'Familienhospizkarenz',
'Sabbatical'
)
);
comment on column employees.absence_type is
'Art der laufenden Langzeitabwesenheit; null, wenn keine besteht. Wird bei der Rückkehr geleert.';
-- Existing absences predate the field and their kind was never recorded, so
-- they stay null rather than being guessed at. The UI shows them as
-- "Langzeitabwesenheit" without a type until someone sets one.
-- ── start_karenz ───────────────────────────────────────────────────
-- Unchanged apart from carrying absence_type through both paths: written
-- straight away when the absence has already begun, or parked in the
-- pending_org_changes payload when it starts later.
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_date')::date;
v_absence_type text := nullif(payload->>'absence_type', '');
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date,
absence_type = v_absence_type
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date', 'absence_type', v_absence_type));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then '' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id,
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
-- ── record_karenz_return ───────────────────────────────────────────
-- Clears absence_type alongside the dates: the absence is over, so leaving
-- its kind behind would make a returned employee look like they were still
-- on one.
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead, karenz_start_date, absence_type
into v_name, v_team_id, v_division_id, v_is_lead, 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
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
manager_id = v_manager,
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 ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- ── apply_due_pending_changes ──────────────────────────────────────
-- Only the two karenz branches change; the rest of the body is carried over
-- unchanged from 20260718160000_dependents_effective_dating.sql.
create or replace function apply_due_pending_changes()
returns int language plpgsql security definer set search_path = public as $$
declare
v_rec record;
v_count int := 0;
v_manager uuid;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
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
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t
join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
manager_id = v_manager,
job_title = coalesce(v_rec.payload->>'new_title', job_title)
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
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
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),
title_prefix = coalesce(
case when v_rec.payload->'person' ? 'title_prefix'
then array(select jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix')) end, title_prefix),
title_suffix = coalesce(
case when v_rec.payload->'person' ? 'title_suffix'
then array(select jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix')) end, title_suffix),
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),
contract_end_date = case
when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date', '')::date
else contract_end_date end,
worker_type = coalesce((v_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = coalesce(
case when v_rec.payload->'role' ? 'work_days'
then array(select jsonb_array_elements_text(v_rec.payload->'role'->'work_days'))::weekday[] end, work_days),
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t
join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
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')::relationship_type,
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;
$$;