diff --git a/actions/employees.ts b/actions/employees.ts index f32137f..1dca6bc 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -129,6 +129,8 @@ export async function recordKarenzReturn(payload: { return_date: string; employment_mode: "unverändert" | "Vollzeit" | "Teilzeit"; weekly_hours?: number; + /** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */ + reduction_reason?: string; }): Promise { return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); } @@ -139,6 +141,12 @@ export async function changeEmployeeData(payload: { person: Record; contract: Record; role: Record; + /** + * Warum sich die Stunden ändern — vertraglich, Bildungs- oder + * Pflegeteilzeit. Kein Feld an der Person: es beschreibt *diese* + * Änderung und steht deshalb in der Historie, nicht im Stammblatt. + */ + hours_reason?: string; }): Promise { return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]); } diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index 3cdaeb8..c661dbf 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -13,6 +13,7 @@ import { Field, SelectField, TextField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import { UN_COUNTRIES } from "@/lib/countries"; +import { STUNDEN_GRUENDE } from "@/lib/absence"; import { fmtFullName, todayIso } from "@/lib/format"; import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr"; import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types"; @@ -64,6 +65,11 @@ export function DatenAendernPanel({ const [employmentType, setEmploymentType] = useState(employee.employment_type); const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours)); + // Der Grund wird erst gefragt, wenn sich die Stunden tatsächlich ändern — + // sonst stünde bei jeder Adressänderung eine Frage im Weg, die niemand + // gestellt hat. + const [stundenGrund, setStundenGrund] = useState(STUNDEN_GRUENDE[0]); + const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours); const [contractType, setContractType] = useState(employee.contract_type); const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? ""); @@ -134,6 +140,10 @@ export function DatenAendernPanel({ emergency_contact_phone: notfallTelefon.trim(), emergency_contact_relation: notfallVerhaeltnis.trim(), }, + // Nur mitschicken, wenn sich die Stunden wirklich ändern — sonst + // stünde bei jeder Adressänderung ein Grund in der Historie, den + // niemand gemeint hat. + hours_reason: stundenGeaendert ? stundenGrund : undefined, contract: { employment_type: employmentType, weekly_hours: Number(weeklyHours), @@ -268,6 +278,16 @@ export function DatenAendernPanel({ disabled={employmentType === "Vollzeit"} onChange={setWeeklyHours} /> + {stundenGeaendert && ( + ({ value: g, label: g }))} + hint="Steht danach in der Historie neben den Stunden." + /> + )} ("unverändert"); + // Warum weniger Stunden: Wiedereingliederungs- oder Elternteilzeit. Beide + // beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb + // steht die Frage hier und nicht in einem zweiten Vorgang danach. + const [reduktionsgrund, setReduktionsgrund] = useState(""); // Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie // hierzulande schreibt. const stundenText = String(employee.weekly_hours).replace(".", ","); @@ -111,6 +115,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos return_date: returnDate, employment_mode: employmentMode, weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined, + reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined, }); setPending(false); if (result.success) { @@ -211,17 +216,29 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos ]} /> {employmentMode === "Teilzeit" && ( - + <> + + ({ value: g, label: g })), + ]} + hint="Steht danach in der Historie, wo auch die Stunden stehen." + /> + )} )} diff --git a/lib/absence.ts b/lib/absence.ts index 2e8a6ba..d1e9628 100644 --- a/lib/absence.ts +++ b/lib/absence.ts @@ -7,18 +7,24 @@ import type { EmploymentStatus } from "./supabase/types"; // a label change (see supabase/migrations/*_absence_type.sql). So the value // is mapped to its display name here, in the one place the UI reads it from. +// Nur echte Abwesenheiten. Eine Teilzeit ist keine: wer in Bildungs-, +// Pflege-, Eltern- oder Wiedereingliederungsteilzeit ist, arbeitet — nur +// weniger. Als „Langzeitabwesenheit" geführt, verschwand die Person aus dem +// Bestand, verlor ihre Berichtslinie an eine Vertretung und zählte in +// Auswertungen nicht mehr mit, obwohl sie jede Woche im Haus war. +// +// Die vier sind deshalb hierher gewandert, wo sie hingehören: zur +// Stundenänderung (siehe STUNDEN_GRUENDE) und zur Rückkehr aus einer +// Abwesenheit (RUECKKEHR_GRUENDE). export const ABSENCE_TYPES = [ "Wochenhilfe (Mutterschutz)", "Elternkarenz (inkl. Väterkarenz)", "Papamonat", "Bildungskarenz", - "Bildungsteilzeit", "Präsenzdienst", "Zivildienst", "Langer Krankenstand", - "Wiedereingliederungsteilzeit", "Pflegekarenz", - "Pflegeteilzeit", "Familienhospizkarenz", "Sabbatical", ] as const; @@ -29,6 +35,25 @@ export function isAbsenceType(value: string | null | undefined): value is Absenc return ABSENCE_TYPES.includes(value as AbsenceType); } +/** + * Warum jemand nach einer Abwesenheit mit weniger Stunden zurückkommt. + * + * Beides sind Ansprüche, keine freien Vereinbarungen — und beide beginnen + * typischerweise genau dann, wenn die Abwesenheit endet. + */ +export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzeit"] as const; + +/** + * Warum sich die Wochenstunden ändern. + * + * Der erste Wert ist der Normalfall; die beiden anderen sind gesetzlich + * geregelte Teilzeiten, die man später wiederfinden können muss — im + * Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken. + */ +export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const; + +export type StundenGrund = (typeof STUNDEN_GRUENDE)[number]; + /** Display names for the stored status values. */ const STATUS_LABELS: Record = { Aktiv: "Aktiv", diff --git a/supabase/migrations/20260814140000_teilzeit_gruende.sql b/supabase/migrations/20260814140000_teilzeit_gruende.sql new file mode 100644 index 0000000..2a986ac --- /dev/null +++ b/supabase/migrations/20260814140000_teilzeit_gruende.sql @@ -0,0 +1,268 @@ +-- Teilzeiten sind keine Abwesenheiten. +-- +-- Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit und Wiedereingliederungs- +-- teilzeit standen in der Liste der Langzeitabwesenheiten. Wer so erfasst +-- wurde, galt als abwesend: die Person verschwand aus dem Bestand, ihre +-- Berichtslinie fiel an eine Vertretung, und in Auswertungen zählte sie nicht +-- mehr mit — obwohl sie jede Woche im Haus war, nur kürzer. +-- +-- Sie wandern deshalb dorthin, wo sie hingehören: +-- +-- * **Wiedereingliederungs- und Elternteilzeit** an die Rückkehr aus einer +-- Abwesenheit. Beide beginnen typischerweise genau dann, wenn die +-- Abwesenheit endet, und beide sind der Grund dafür, dass jemand mit +-- weniger Stunden zurückkommt. +-- * **Bildungs- und Pflegeteilzeit** an die Stundenänderung unter „Daten +-- ändern", neben der gewöhnlichen vertraglichen Änderung. +-- +-- Der Grund wird **mit der Änderung** festgehalten, nicht als Zustand an der +-- Person. Ein Zustand müsste gepflegt werden — es gibt aber niemanden, der +-- nachträgt, wann eine Bildungsteilzeit endet, und ein Feld, das schleichend +-- veraltet, ist schlimmer als keines. In der Historie steht der Grund dort, +-- wo auch der geänderte Wert steht, und bleibt dort dauerhaft lesbar. +-- +-- **Die Prüfbedingung auf absence_type bleibt unverändert.** Drei Personen +-- tragen die Werte gerade (Pflegeteilzeit, Wiedereingliederungsteilzeit); sie +-- zu verbieten hiesse, bestehende Zeilen ungültig zu machen. Aus der Auswahl +-- verschwinden sie, die Geschichte bleibt lesbar. + +CREATE OR REPLACE FUNCTION public.record_karenz_return(payload jsonb) + RETURNS void + LANGUAGE plpgsql + SET search_path TO 'public', 'pg_temp' +AS $function$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_return_date date := (payload->>'return_date')::date; + v_name text; + v_employment_type employment_type; + v_weekly_hours numeric; + v_karenz_start date; + v_absence_type text; + -- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs- + -- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird. + v_grund text := nullif(payload->>'reduction_reason', ''); +begin + perform require_hr_admin(); + select first_name || ' ' || last_name, karenz_start_date, absence_type + into v_name, v_karenz_start, v_absence_type + from employees where id = v_employee_id; + + if v_karenz_start is not null and v_return_date <= v_karenz_start then + raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start; + end if; + + if payload->>'employment_mode' = 'Vollzeit' then + v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5; + elsif payload->>'employment_mode' = 'Teilzeit' then + v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric; + end if; + + if v_return_date <= current_date then + -- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit + -- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie + -- fällt automatisch von der Vertretung auf ihn zurück. + update employees set + status = 'Aktiv', + karenz_return_date = null, + karenz_start_date = null, + absence_type = null, + employment_type = coalesce(v_employment_type, employment_type), + weekly_hours = coalesce(v_weekly_hours, weekly_hours) + where id = v_employee_id; + else + update employees set karenz_return_date = v_return_date where id = v_employee_id; + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'karenz_return', v_return_date, + jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours)); + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_return_date, 'Rückkehr', + 'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date + || case when payload->>'employment_mode' = 'Teilzeit' + then ', reduziert auf ' || (payload->>'weekly_hours') || ' h' + || coalesce(' (' || v_grund || ')', '') + else '' end); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, '')); +end; +$function$; + + +CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb) + RETURNS void + LANGUAGE plpgsql + SET search_path TO 'public', 'pg_temp' +AS $function$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date); + v_old employees%rowtype; + v_name text; + v_person_changes jsonb := '[]'::jsonb; + v_contract_changes jsonb := '[]'::jsonb; + v_person jsonb := payload->'person'; + v_contract jsonb := payload->'contract'; + v_role jsonb := payload->'role'; + v_immediate boolean; + v_new_work_days text[]; + v_new_title_prefix text[]; + v_new_title_suffix text[]; + -- Warum sich die Stunden ändern. Kein Feld an der Person, sondern eine + -- Eigenschaft *dieser* Änderung — es gibt niemanden, der später + -- nachträgt, wann eine Bildungsteilzeit endet. In der Historie steht + -- der Grund damit dort, wo auch der Wert steht. + v_stunden_grund text := nullif(payload->>'hours_reason', ''); + v_pending_id uuid; +begin + perform require_hr_admin(); + select * into v_old from employees where id = v_employee_id; + v_name := v_old.first_name || ' ' || v_old.last_name; + v_immediate := v_effective_date <= current_date; + + -- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt", + -- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung + -- auf null gemeldet. + if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if; + if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if; + if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if; + -- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und + -- „2026-08-03" nicht als Änderung gelten. + if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if; + if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if; + if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if; + if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if; + if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if; + if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if; + if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if; + if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if; + if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if; + if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if; + if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if; + if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if; + + if v_person ? 'title_prefix' then + v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}'); + v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)', + array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', ')); + end if; + if v_person ? 'title_suffix' then + v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}'); + v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)', + array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', ')); + end if; + + if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if; + -- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen. + if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if; + if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if; + if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if; + + if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if; + if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if; + if v_role ? 'work_days' then + v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}'); + v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage', + array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', ')); + end if; + if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if; + if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if; + if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if; + if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if; + if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if; + if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if; + if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if; + + if v_immediate then + update employees set + first_name = coalesce(v_person->>'first_name', first_name), + last_name = coalesce(v_person->>'last_name', last_name), + gender = coalesce((v_person->>'gender')::gender_type, gender), + birth_date = coalesce((v_person->>'birth_date')::date, birth_date), + sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer), + nationality = coalesce(v_person->>'nationality', nationality), + address = coalesce(v_person->>'address', address), + postal_code = coalesce(v_person->>'postal_code', postal_code), + city = coalesce(v_person->>'city', city), + address_country = coalesce(v_person->>'address_country', address_country), + email = coalesce(v_person->>'email', email), + phone = coalesce(v_person->>'phone', phone), + emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end, + emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end, + emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end, + title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end, + title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end, + employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours), + contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type), + contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end, + worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type), + collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement), + work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end, + is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat), + has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen), + is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung), + is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level), + has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz), + -- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein + -- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der + -- Vorgang schlüge fehl, statt das Offensichtliche zu tun. + kuendigungsschutz_bis = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_bis' + then nullif(v_role->>'kuendigungsschutz_bis','')::date + else kuendigungsschutz_bis end + else null + end, + dienstwagen_art = case + when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then + coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner') + else null + end + where id = v_employee_id; + elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'contract_change', v_effective_date, payload) + returning id into v_pending_id; + end if; + + if jsonb_array_length(v_person_changes) > 0 then + insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id) + values (v_employee_id, v_effective_date, 'Stammdatenänderung', + 'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id); + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes) + values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, + app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes); + end if; + + if jsonb_array_length(v_contract_changes) > 0 then + insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id) + values (v_employee_id, v_effective_date, 'Vertragsänderung', + 'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date + || case when v_stunden_grund is not null and v_contract ? 'weekly_hours' + then ' — ' || v_stunden_grund else '' end, + v_contract_changes, v_pending_id); + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes) + values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, + app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes); + end if; +end; +$function$; + + +-- Selbstprüfung. +do $$ +declare + v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure); + v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure); +begin + if v_ret not like '%reduction_reason%' then + raise exception 'record_karenz_return nimmt den Grund nicht entgegen'; + end if; + if v_chg not like '%hours_reason%' then + raise exception 'change_employee_data nimmt den Grund der Stundenänderung nicht entgegen'; + end if; +end +$$; diff --git a/tests/unit/absence.test.ts b/tests/unit/absence.test.ts index 9ad5e07..aec3d7e 100644 --- a/tests/unit/absence.test.ts +++ b/tests/unit/absence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ABSENCE_TYPES, absenceLabel, isAbsenceType, statusLabel } from "@/lib/absence"; +import { ABSENCE_TYPES, absenceLabel, isAbsenceType, RUECKKEHR_GRUENDE, statusLabel, STUNDEN_GRUENDE } from "@/lib/absence"; // The stored employment status is still 'Karenz' — renaming the enum value // would mean rewriting every stored function that spells it. The rename @@ -42,8 +42,10 @@ describe("absenceLabel", () => { }); describe("ABSENCE_TYPES", () => { - it("holds the thirteen kinds, in the order they are offered", () => { - expect(ABSENCE_TYPES).toHaveLength(13); + it("holds the ten kinds, in the order they are offered", () => { + // Waren dreizehn. Die vier Teilzeiten sind dorthin gewandert, wo sie + // hingehören — siehe unten. + expect(ABSENCE_TYPES).toHaveLength(10); expect(ABSENCE_TYPES[0]).toBe("Wochenhilfe (Mutterschutz)"); expect(ABSENCE_TYPES.at(-1)).toBe("Sabbatical"); }); @@ -54,3 +56,47 @@ describe("ABSENCE_TYPES", () => { expect(isAbsenceType(null)).toBe(false); }); }); + +// Eine Teilzeit ist keine Abwesenheit. Wer in Bildungs-, Pflege-, Eltern- +// oder Wiedereingliederungsteilzeit als „langzeitabwesend" geführt wurde, +// verschwand aus dem Bestand, verlor die Berichtslinie an eine Vertretung und +// zählte in Auswertungen nicht mehr mit — obwohl er jede Woche im Haus war, +// nur kürzer. +const TEILZEITEN = ["Bildungsteilzeit", "Elternteilzeit", "Wiedereingliederungsteilzeit", "Pflegeteilzeit"]; + +describe("Teilzeiten sind keine Abwesenheiten", () => { + it("führt keine Teilzeit mehr als Abwesenheit", () => { + for (const t of TEILZEITEN) { + expect(ABSENCE_TYPES).not.toContain(t); + expect(isAbsenceType(t)).toBe(false); + } + }); + + it("behält die Karenzen — sie setzen aus, statt zu reduzieren", () => { + // Die Wörter ähneln sich, die Sachverhalte nicht: Bildungskarenz ist + // eine Abwesenheit, Bildungsteilzeit sind weniger Stunden. + for (const t of ["Elternkarenz (inkl. Väterkarenz)", "Bildungskarenz", "Pflegekarenz"]) { + expect(ABSENCE_TYPES).toContain(t); + } + }); +}); + +describe("Gründe für weniger Stunden", () => { + it("bietet bei der Rückkehr die zwei an, die dort hingehören", () => { + expect([...RUECKKEHR_GRUENDE]).toEqual(["Wiedereingliederungsteilzeit", "Elternteilzeit"]); + }); + + it("bietet bei der Stundenänderung die übrigen an, mit dem Normalfall zuerst", () => { + expect(STUNDEN_GRUENDE[0]).toBe("Vertragliche Stundenänderung"); + expect([...STUNDEN_GRUENDE]).toContain("Bildungsteilzeit"); + expect([...STUNDEN_GRUENDE]).toContain("Pflegeteilzeit"); + }); + + it("verteilt die vier Teilzeiten vollständig und ohne Überschneidung", () => { + // Keine darf verlorengehen, und keine an beiden Stellen auftauchen — + // sonst hinge dieselbe Sache an zwei Vorgängen. + const verteilt = [...RUECKKEHR_GRUENDE, ...STUNDEN_GRUENDE].filter((g) => TEILZEITEN.includes(g)); + expect(new Set(verteilt)).toEqual(new Set(TEILZEITEN)); + expect(verteilt.length).toBe(TEILZEITEN.length); + }); +});