Org assignment history, mobile support, and a correctness pass

Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
This commit is contained in:
2026-07-24 23:38:10 +02:00
parent f96773da0f
commit 79f0e19bf8
101 changed files with 6120 additions and 700 deletions

View File

@@ -0,0 +1,202 @@
-- Split the combined "Straße Nr, PLZ Ort" address string into three fields.
--
-- employees.address now holds only Straße + Hausnummer (still a single
-- free-text line); postal_code and city are their own columns so the UI
-- can offer them as separate inputs and reports/exports can filter or
-- group by them independently.
alter table employees add column if not exists postal_code text;
alter table employees add column if not exists city text;
-- Best-effort backfill for existing rows seeded in the old combined format
-- (supabase/seed.ts previously wrote "Straße Nr, PLZ Ort"). Only rewrites
-- rows that actually match that exact "<street>, <postal> <city>" shape;
-- anything else (already-split rows, hand-edited free text, no comma) is
-- left untouched for HR to fill in via "Daten ändern".
update employees
set
postal_code = trim(split_part(split_part(address, ', ', 2), ' ', 1)),
city = trim(substring(split_part(address, ', ', 2) from '\S+\s+(.*)$')),
address = trim(split_part(address, ', ', 1))
where address ~ '^[^,]+,\s*\d{3,6}\s*[[:alpha:]].*$';
-- ── change_employee_data: recognize postal_code/city as person fields ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_immediate boolean;
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); 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),
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
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 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 array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror the same postal_code/city handling
-- in the deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by 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,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
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,
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),
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
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;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,183 @@
-- Position validity window + delete capability.
--
-- 1. Positions had no "gültig ab" (valid_from) date — nothing recorded
-- since when a position is actually meant to be active, so a position
-- created today for a future need could immediately be staffed/hired
-- into.
-- 2. There was no way to remove a position again once created (no UI, no
-- Server Action, no RPC) — a mis-created or no-longer-needed open
-- requisition was stuck forever.
-- 3. Neither staff_position_internally nor hire_employee checked the
-- position's validity window before assigning an employee to it.
alter table positions add column if not exists valid_from date not null default current_date;
-- ── Position ausschreiben: now records valid_from ────────────────
create or replace function create_position(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_superior_id uuid := (payload->>'superior_employee_id')::uuid;
v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false);
v_team_id uuid;
v_valid_from date := coalesce(nullif(payload->>'valid_from', '')::date, current_date);
begin
perform require_hr_admin();
if v_is_lead then
v_team_id := (payload->>'team_id')::uuid;
else
select team_id into v_team_id from employees where id = v_superior_id;
end if;
insert into positions (title, team_id, is_lead, reports_to_employee_id, valid_from)
values (payload->>'title', v_team_id, v_is_lead, v_superior_id, v_valid_from)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben, gültig ab ' || v_valid_from);
return v_id;
end;
$$;
-- ── Position löschen ───────────────────────────────────────────────
-- Only open (unfilled) positions can be deleted — a filled position is
-- already tied to an employee's history/audit trail and to whoever holds it.
create or replace function delete_position(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid;
if not found then
raise exception 'Position nicht gefunden.';
end if;
if v_position.status <> 'open' then
raise exception 'Nur offene Positionen können gelöscht werden.';
end if;
delete from positions where id = v_position.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Position gelöscht', v_position.title, 'Position ' || v_position.position_number || ' gelöscht');
end;
$$;
-- ── Intern besetzen: reject if the position isn't valid yet ─────────
create or replace function staff_position_internally(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if v_position.valid_from > current_date then
raise exception 'Die Position ist erst ab % gültig.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id);
update employees set
team_id = v_position.team_id,
job_title = v_position.title,
source = 'Intern',
is_lead = case when v_position.is_lead then true else is_lead end,
org_level = case when v_position.is_lead then 2 else org_level end,
manager_id = v_manager
where id = v_employee_id;
if v_position.is_lead then
update employees set manager_id = v_employee_id
where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten';
end if;
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id
where id = v_position.id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title);
end;
$$;
-- ── Neueinstellung: reject if entry_date precedes the position's validity ──
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;

View File

@@ -0,0 +1,309 @@
-- "Rolle & Anstellung" — role/employment classification that HR tracked
-- outside the system until now: worker category (Angestellte:r vs.
-- Arbeiter:in), collective agreement, contracted work days, and a handful
-- of eligibility/reporting flags (Betriebsrat, Dienstwagen, laterale
-- Führung, C-Level).
create type worker_type as enum ('Angestellte:r', 'Arbeiter:in');
create type collective_agreement as enum ('Handel', 'Süßwaren');
alter table employees add column if not exists worker_type worker_type not null default 'Angestellte:r';
alter table employees add column if not exists collective_agreement collective_agreement not null default 'Handel';
alter table employees add column if not exists work_days text[] not null default '{Mo,Di,Mi,Do,Fr}';
alter table employees add column if not exists is_betriebsrat boolean not null default false;
alter table employees add column if not exists has_dienstwagen boolean not null default false;
alter table employees add column if not exists is_laterale_fuehrung boolean not null default false;
alter table employees add column if not exists is_c_level boolean not null default false;
alter table employees add constraint chk_work_days_valid check (
work_days <@ array['Mo','Di','Mi','Do','Fr','Sa','So']::text[] and cardinality(work_days) > 0
);
-- ── Neueinstellung: accepts the new role/employment fields ─────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date,
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Handel'),
case when payload ? 'work_days' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'work_days') elem), '{}') else '{Mo,Di,Mi,Do,Fr}' end,
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)
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Daten ändern: recognize a "role" section alongside person/contract ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days 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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_role ? 'worker_type' and (v_role->>'worker_type') <> v_old.worker_type::text then v_contract_changes := array_append(v_contract_changes, 'Angestellte:r/Arbeiter:in'); end if;
if v_role ? 'collective_agreement' and (v_role->>'collective_agreement') <> v_old.collective_agreement::text then v_contract_changes := array_append(v_contract_changes, 'Kollektivvertrag'); 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), '{}');
if v_new_work_days is distinct from v_old.work_days then v_contract_changes := array_append(v_contract_changes, 'Arbeitstage'); end if;
end if;
if v_role ? 'is_betriebsrat' and (v_role->>'is_betriebsrat')::boolean <> v_old.is_betriebsrat then v_contract_changes := array_append(v_contract_changes, 'Betriebsrat'); end if;
if v_role ? 'has_dienstwagen' and (v_role->>'has_dienstwagen')::boolean <> v_old.has_dienstwagen then v_contract_changes := array_append(v_contract_changes, 'Dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' and (v_role->>'is_laterale_fuehrung')::boolean <> v_old.is_laterale_fuehrung then v_contract_changes := array_append(v_contract_changes, 'Laterale Führung'); end if;
if v_role ? 'is_c_level' and (v_role->>'is_c_level')::boolean <> v_old.is_c_level then v_contract_changes := array_append(v_contract_changes, 'C-Level'); 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),
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)
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 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 array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror the same role-field handling in the
-- deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by 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,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
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,
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),
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 = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
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;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,78 @@
-- "Angehörige" — dependents/family members tracked per employee (spouse,
-- children, ...), relevant for payroll/insurance. One employee can have
-- several. Editing is add/remove only — fix a mistaken entry by deleting
-- and re-adding it, same as "Position löschen" — no in-place edit RPC.
create table employee_dependents (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
first_name text not null,
last_name text not null,
relationship text not null check (relationship in ('Ehepartner:in', 'Lebenspartner:in', 'Kind', 'Sonstige')),
sv_nummer text,
birth_date date not null,
created_at timestamptz not null default now()
);
create index on employee_dependents (employee_id);
-- RLS only narrows what an already-GRANTed role may do; the "grant all ...
-- to anon, authenticated, service_role" default privilege (see
-- 20260714120500_default_grants.sql) already covers this new table.
alter table employee_dependents enable row level security;
create policy "employee_dependents_hr_all" on employee_dependents for all
using (is_hr_user()) with check (is_hr_user());
-- ── Angehörige:n hinzufügen ───────────────────────────────────────────
create or replace function add_employee_dependent(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (
v_employee_id, payload->>'first_name', payload->>'last_name', payload->>'relationship',
nullif(payload->>'sv_nummer', ''), (payload->>'birth_date')::date
)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r hinzugefügt', v_employee_name, v_employee_id,
(payload->>'first_name') || ' ' || (payload->>'last_name') || ' (' || (payload->>'relationship') || ')'
);
return v_id;
end;
$$;
-- ── Angehörige:n entfernen ────────────────────────────────────────────
create or replace function delete_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_dep employee_dependents%rowtype;
v_employee_name text;
begin
perform require_hr_admin();
select * into v_dep from employee_dependents where id = (payload->>'dependent_id')::uuid;
if not found then
raise exception 'Angehörige:r nicht gefunden.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_dep.employee_id;
delete from employee_dependents where id = v_dep.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r entfernt', v_employee_name, v_dep.employee_id,
v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || ')'
);
end;
$$;

View File

@@ -0,0 +1,321 @@
-- Vor- und nachgestellte akademische Titel (Bundeskanzleramt/ELDA-Standardliste,
-- see lib/titles.ts) — e.g. "Dr. Max Mustermann, MSc". A person can hold
-- several of either, so both are text[] like work_days.
alter table employees add column if not exists title_prefix text[] not null default '{}';
alter table employees add column if not exists title_suffix text[] not null default '{}';
alter table employees add constraint chk_title_prefix_valid check (
title_prefix <@ array['Dr.','DDr.','Dipl.-Ing.','Ing.','Mag.','Mag. (FH)','MMag.','Dkfm.','Priv.-Doz.','Prof.']::text[]
);
alter table employees add constraint chk_title_suffix_valid check (
title_suffix <@ array['BA','BSc','BEd','BBA','LLB','MA','MSc','MBA','MEd','LLM','PhD','MBL']::text[]
);
-- ── Neueinstellung: accepts title_prefix/title_suffix ───────────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
title_prefix, title_suffix
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date,
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Handel'),
case when payload ? 'work_days' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'work_days') elem), '{}') else '{Mo,Di,Mi,Do,Fr}' end,
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),
case when payload ? 'title_prefix' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'title_prefix') elem), '{}') else '{}' end,
case when payload ? 'title_suffix' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'title_suffix') elem), '{}') else '{}' end
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Daten ändern: person section recognizes title_prefix/title_suffix ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); 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), '{}');
if v_new_title_prefix is distinct from v_old.title_prefix then v_person_changes := array_append(v_person_changes, 'Titel (vorangestellt)'); end if;
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), '{}');
if v_new_title_suffix is distinct from v_old.title_suffix then v_person_changes := array_append(v_person_changes, 'Titel (nachgestellt)'); end if;
end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_role ? 'worker_type' and (v_role->>'worker_type') <> v_old.worker_type::text then v_contract_changes := array_append(v_contract_changes, 'Angestellte:r/Arbeiter:in'); end if;
if v_role ? 'collective_agreement' and (v_role->>'collective_agreement') <> v_old.collective_agreement::text then v_contract_changes := array_append(v_contract_changes, 'Kollektivvertrag'); 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), '{}');
if v_new_work_days is distinct from v_old.work_days then v_contract_changes := array_append(v_contract_changes, 'Arbeitstage'); end if;
end if;
if v_role ? 'is_betriebsrat' and (v_role->>'is_betriebsrat')::boolean <> v_old.is_betriebsrat then v_contract_changes := array_append(v_contract_changes, 'Betriebsrat'); end if;
if v_role ? 'has_dienstwagen' and (v_role->>'has_dienstwagen')::boolean <> v_old.has_dienstwagen then v_contract_changes := array_append(v_contract_changes, 'Dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' and (v_role->>'is_laterale_fuehrung')::boolean <> v_old.is_laterale_fuehrung then v_contract_changes := array_append(v_contract_changes, 'Laterale Führung'); end if;
if v_role ? 'is_c_level' and (v_role->>'is_c_level')::boolean <> v_old.is_c_level then v_contract_changes := array_append(v_contract_changes, 'C-Level'); 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),
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)
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 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 array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror title_prefix/title_suffix handling
-- in the deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by 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,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
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,
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 = case when v_rec.payload->'person' ? 'title_prefix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}') else title_prefix end,
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}') else title_suffix end,
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 = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
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;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,214 @@
-- Angehörige add/remove now take a "Wirksam ab" like every other mutation
-- in Daten ändern — a future-dated add/remove is queued the same way
-- transfer/promotion/contract changes already are (see
-- 20260714120050_pending_org_changes.sql) and picked up by the existing
-- apply_due_pending_changes() cron job once due.
alter table pending_org_changes drop constraint pending_org_changes_change_type_check;
alter table pending_org_changes add constraint pending_org_changes_change_type_check check (change_type in (
'transfer', 'promotion', 'karenz_start', 'karenz_return', 'contract_change', 'reorg', 'dependent_add', 'dependent_remove'
));
-- ── Angehörige:n hinzufügen: now effective-dated ─────────────────────
-- Return type changes uuid -> void (a deferred add has no row yet to
-- return an id for), which CREATE OR REPLACE can't do in place.
drop function if exists add_employee_dependent(jsonb);
create function add_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_dep_name text := (payload->>'first_name') || ' ' || (payload->>'last_name');
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
if v_effective_date <= current_date then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (
v_employee_id, payload->>'first_name', payload->>'last_name', payload->>'relationship',
nullif(payload->>'sv_nummer', ''), (payload->>'birth_date')::date
);
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'dependent_add', v_effective_date, payload);
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (
v_employee_id, v_effective_date, 'Stammdatenänderung',
'Angehörige:r hinzugefügt: ' || v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date
);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r hinzugefügt', v_employee_name, v_employee_id,
v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date
);
end;
$$;
-- ── Angehörige:n entfernen: now effective-dated ──────────────────────
create or replace function delete_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_dep employee_dependents%rowtype;
v_employee_name text;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
begin
perform require_hr_admin();
select * into v_dep from employee_dependents where id = (payload->>'dependent_id')::uuid;
if not found then
raise exception 'Angehörige:r nicht gefunden.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_dep.employee_id;
if v_effective_date <= current_date then
delete from employee_dependents where id = v_dep.id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_dep.employee_id, 'dependent_remove', v_effective_date, jsonb_build_object('dependent_id', v_dep.id));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (
v_dep.employee_id, v_effective_date, 'Stammdatenänderung',
'Angehörige:r entfernt: ' || v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date
);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r entfernt', v_employee_name, v_dep.employee_id,
v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date
);
end;
$$;
-- ── apply_due_pending_changes: dependent_add/dependent_remove branches ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by 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,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
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,
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 = case when v_rec.payload->'person' ? 'title_prefix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}') else title_prefix end,
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}') else title_suffix end,
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 = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
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 = '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',
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;
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;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,107 @@
-- HR-Notizen: add-only Notizen zu einem Mitarbeiter, mit optionalem
-- "Wiedervorlage am"-Datum. Kein Bearbeiten/Löschen — ein Fehler wird nicht
-- korrigiert, sondern bleibt sichtbar (ggf. per neuer Notiz richtiggestellt),
-- gleicher Append-only-Geist wie employee_history/audit_log.
--
-- Bewusst NICHT nach Autor gescoped und NICHT effective-dated: anders als
-- employee_dependents sieht jede aktive HR-Person jede offene Notiz,
-- unabhängig davon wer sie geschrieben hat oder zu wem sie gehört ("Meine
-- Notizen" ist trotz des Namens ein geteiltes Team-Postfach), und eine
-- Notiz hat keinen "existiert erst ab einem künftigen Datum"-Zustand wie
-- eine Versetzung/Beförderung.
--
-- Kein Eintrag in employee_history: history_event_type ist ein fixer Enum
-- (siehe 20260601000000_initial_schema.sql) ohne passenden Wert, und
-- Historie ist explizit eine Beschäftigungsereignis-Timeline. Jede Mutation
-- schreibt stattdessen nur einen audit_log-Eintrag.
create table employee_notes (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
author_user_id uuid references auth.users(id),
author_name text not null,
category text not null default 'Allgemein' check (category in (
'Allgemein', 'Vertraulich', 'Personalgespräch', 'Wiedervorlage', 'Lob / Anerkennung'
)),
note_text text not null,
due_date date,
done boolean not null default false,
done_at timestamptz,
done_by uuid references auth.users(id),
created_at timestamptz not null default now()
);
create index on employee_notes (employee_id);
-- Deckt die "Meine Notizen"-Postfach-Query (loadOpenNotes) ab, die immer
-- auf done = false filtert.
create index on employee_notes (created_at desc) where not done;
-- RLS narrows only what an already-GRANTed role may do; die
-- "grant all ... to anon, authenticated, service_role"-Default-Privilegien
-- (20260714120500_default_grants.sql) decken die neue Tabelle bereits ab.
alter table employee_notes enable row level security;
-- Eine einzige Blanket-Policy, gleiches Muster wie `positions`: offen vs.
-- erledigt ist ein reiner App-Filter, nie eine RLS-Unterscheidung — jede
-- aktive HR-Person darf jede Notiz lesen/schreiben.
create policy "employee_notes_hr_all" on employee_notes for all
using (is_hr_user()) with check (is_hr_user());
-- ── HR-Notiz hinzufügen ───────────────────────────────────────────────
create or replace function add_employee_note(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
v_category text := coalesce(nullif(payload->>'category', ''), 'Allgemein');
v_note_text text := payload->>'note_text';
v_due_date date := nullif(payload->>'due_date', '')::date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
if coalesce(btrim(v_note_text), '') = '' then
raise exception 'Notiztext darf nicht leer sein.';
end if;
insert into employee_notes (employee_id, author_user_id, author_name, category, note_text, due_date)
values (v_employee_id, auth.uid(), current_actor_name(), v_category, v_note_text, v_due_date)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'HR-Notiz hinzugefügt', v_employee_name, v_employee_id,
'[' || v_category || '] ' || left(v_note_text, 200) ||
case when v_due_date is not null then ', Wiedervorlage am ' || v_due_date else '' end
);
return v_id;
end;
$$;
-- ── HR-Notiz als erledigt markieren ───────────────────────────────────
create or replace function complete_employee_note(payload jsonb)
returns void language plpgsql as $$
declare
v_note employee_notes%rowtype;
v_employee_name text;
begin
perform require_hr_admin();
select * into v_note from employee_notes where id = (payload->>'note_id')::uuid;
if not found then
raise exception 'Notiz nicht gefunden.';
end if;
if v_note.done then
raise exception 'Notiz ist bereits erledigt.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_note.employee_id;
update employee_notes set done = true, done_at = now(), done_by = auth.uid() where id = v_note.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'HR-Notiz erledigt', v_employee_name, v_note.employee_id,
'[' || v_note.category || '] ' || left(v_note.note_text, 200)
);
end;
$$;

View File

@@ -0,0 +1,120 @@
-- Org-assignment history, so the Organigramm can be shown as of any date.
--
-- Until now `employees` carried only the *current* placement (manager_id,
-- team_id, division_id, job_title, is_lead, org_level) and every mutation
-- overwrote it in place. employee_history recorded that something happened,
-- but only as free text — no old/new values — so a past reporting line was
-- gone for good. Forward-looking dates already worked (pending_org_changes
-- holds not-yet-due changes structurally); it was the past that could not be
-- reconstructed. This migration adds the missing timeline.
--
-- Captured by a trigger rather than by editing the mutating RPCs: there are
-- ~70 `update employees` statements spread over fifteen migrations (several
-- of which redefine the same function repeatedly), so per-RPC bookkeeping
-- would miss paths today and again with every future RPC. A trigger on the
-- table catches all of them, including ones not written yet.
create table employee_assignments (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
manager_id uuid references employees(id) on delete set null,
team_id uuid references teams(id),
division_id uuid not null references divisions(id),
job_title text not null,
is_lead boolean not null default false,
org_level int not null,
valid_from date not null,
-- Exclusive upper bound; null means "still in force". Note this tracks
-- *placement*, not employment: the row of someone who has left stays open,
-- because whether they were employed on a given date is derived separately
-- from entry/exit/karenz dates. Keeping the two apart is what lets a
-- rehire reuse the same open row instead of needing it reopened.
valid_to date,
created_at timestamptz not null default now(),
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
);
create index on employee_assignments (employee_id, valid_from desc);
create index on employee_assignments (valid_from);
-- At most one open interval per employee — the invariant the trigger relies
-- on when it looks up "the current row" to close.
create unique index employee_assignments_one_open on employee_assignments (employee_id) where valid_to is null;
alter table employee_assignments enable row level security;
create policy "employee_assignments_hr_read" on employee_assignments for select using (is_hr_user());
-- ── Backfill ───────────────────────────────────────────────────────
-- One open interval per employee, starting at their entry date, holding
-- today's placement. Changes made *before* this migration are not
-- recoverable — employee_history never stored structured old values — so a
-- Stichtag before today's date shows the placement as it stands now for
-- anyone whose assignment predates this table. Everything from here on is
-- exact.
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
select id, manager_id, team_id, division_id, job_title, is_lead, org_level, entry_date
from employees;
-- ── Trigger ────────────────────────────────────────────────────────
-- The date a change takes effect is normally the day it is written: an RPC
-- with a future effective date does not touch `employees` at all (it queues
-- into pending_org_changes, and apply_due_pending_changes writes it on the
-- due date), and one dated today writes immediately. A *backdated* change is
-- the exception — it writes immediately although it should take effect
-- earlier — so callers may set `app.effective_date` for the transaction to
-- say so explicitly; unset, it falls back to the current date.
create or replace function fn_track_employee_assignment()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_date date := coalesce(nullif(current_setting('app.effective_date', true), '')::date, current_date);
v_open_from date;
begin
if tg_op = 'INSERT' then
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level,
coalesce(new.entry_date, v_date));
return new;
end if;
if new.manager_id is not distinct from old.manager_id
and new.team_id is not distinct from old.team_id
and new.division_id is not distinct from old.division_id
and new.job_title is not distinct from old.job_title
and new.is_lead is not distinct from old.is_lead
and new.org_level is not distinct from old.org_level then
return new;
end if;
select valid_from into v_open_from
from employee_assignments where employee_id = new.id and valid_to is null;
if v_open_from is null then
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date);
elsif v_date <= v_open_from then
-- Two changes on the same day (or a backdate landing inside the open
-- interval): overwrite in place, so no zero-length or inverted interval
-- is ever stored.
update employee_assignments
set manager_id = new.manager_id, team_id = new.team_id, division_id = new.division_id,
job_title = new.job_title, is_lead = new.is_lead, org_level = new.org_level
where employee_id = new.id and valid_to is null;
else
update employee_assignments set valid_to = v_date where employee_id = new.id and valid_to is null;
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date);
end if;
return new;
end;
$$;
drop trigger if exists trg_track_employee_assignment on employees;
create trigger trg_track_employee_assignment
after insert or update on employees
for each row execute function fn_track_employee_assignment();
grant all on table employee_assignments to anon, authenticated, service_role;