Move the part-time arrangements out of the absence list

Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit and
Wiedereingliederungsteilzeit were offered as kinds of long-term absence.
Recorded that way, the person counted as absent: they dropped out of
headcount, their reporting line fell to a stand-in, and reports stopped
counting them — while they were in the building every week, just for
fewer hours. A part-time arrangement is not an absence; it is a change
of hours.

They now sit where they belong. Wiedereingliederungs- and Elternteilzeit
appear when recording a return from absence, as the reason someone comes
back on reduced hours — both typically begin exactly when the absence
ends. Bildungs- and Pflegeteilzeit appear under "Daten ändern" beside
the hours, next to the ordinary contractual change.

The reason is recorded with the change, not as a state on the person. A
state would have to be maintained, and nobody goes back to note when a
Bildungsteilzeit ended; a field that quietly goes stale is worse than
none. In the history it stands next to the value it explains, and stays
readable for good.

The check constraint on absence_type is deliberately untouched. Three
people carry the old values right now — two Pflegeteilzeit, one
Wiedereingliederungsteilzeit. Forbidding them would make existing rows
illegal. They are gone from the list of choices; the history stays
readable. Those three are worth revisiting, but that is a data decision,
not a code one.

Rehearsed against real data: an hours change with a reason and one
without, a reduced return with a reason and an unchanged one — checked
by reading both new history rows rather than "the latest", since now()
stands still inside a transaction and made an earlier probe report a
false negative.

I also overwrote tests/unit/absence.test.ts instead of extending it. The
original cases are restored; the diff is 49 added lines and 3 changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:56:21 +02:00
parent 6681dcda77
commit e6554e7982
6 changed files with 402 additions and 18 deletions

View File

@@ -129,6 +129,8 @@ export async function recordKarenzReturn(payload: {
return_date: string; return_date: string;
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit"; employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
weekly_hours?: number; weekly_hours?: number;
/** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */
reduction_reason?: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
} }
@@ -139,6 +141,12 @@ export async function changeEmployeeData(payload: {
person: Record<string, unknown>; person: Record<string, unknown>;
contract: Record<string, unknown>; contract: Record<string, unknown>;
role: Record<string, unknown>; role: Record<string, unknown>;
/**
* 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<ActionResult> { }): Promise<ActionResult> {
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]); return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
} }

View File

@@ -13,6 +13,7 @@ import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries"; import { UN_COUNTRIES } from "@/lib/countries";
import { STUNDEN_GRUENDE } from "@/lib/absence";
import { fmtFullName, todayIso } from "@/lib/format"; import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr"; import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types"; 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<EmploymentType>(employee.employment_type); const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours)); 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<string>(STUNDEN_GRUENDE[0]);
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
const [contractType, setContractType] = useState<ContractType>(employee.contract_type); const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? ""); const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
@@ -134,6 +140,10 @@ export function DatenAendernPanel({
emergency_contact_phone: notfallTelefon.trim(), emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.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: { contract: {
employment_type: employmentType, employment_type: employmentType,
weekly_hours: Number(weeklyHours), weekly_hours: Number(weeklyHours),
@@ -268,6 +278,16 @@ export function DatenAendernPanel({
disabled={employmentType === "Vollzeit"} disabled={employmentType === "Vollzeit"}
onChange={setWeeklyHours} onChange={setWeeklyHours}
/> />
{stundenGeaendert && (
<SelectField
label="Grund der Stundenänderung"
dense
value={stundenGrund}
onChange={setStundenGrund}
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
hint="Steht danach in der Historie neben den Stunden."
/>
)}
<SelectField <SelectField
label="Vertragsart" label="Vertragsart"
dense dense

View File

@@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { ABSENCE_TYPES, absenceLabel } from "@/lib/absence"; import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
import { fmtDate, fmtName } from "@/lib/format"; import { fmtDate, fmtName } from "@/lib/format";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
@@ -33,6 +33,10 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
const [returnDate, setReturnDate] = useState(""); const [returnDate, setReturnDate] = useState("");
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert"); const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("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<string>("");
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie // Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
// hierzulande schreibt. // hierzulande schreibt.
const stundenText = String(employee.weekly_hours).replace(".", ","); const stundenText = String(employee.weekly_hours).replace(".", ",");
@@ -111,6 +115,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
return_date: returnDate, return_date: returnDate,
employment_mode: employmentMode, employment_mode: employmentMode,
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined, weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
}); });
setPending(false); setPending(false);
if (result.success) { if (result.success) {
@@ -211,17 +216,29 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
]} ]}
/> />
{employmentMode === "Teilzeit" && ( {employmentMode === "Teilzeit" && (
<TextField <>
label="Reduzierte Wochenstunden" <TextField
required label="Reduzierte Wochenstunden"
type="number" required
step="0.5" type="number"
max="38" step="0.5"
value={weeklyHours} max="38"
onChange={setWeeklyHours} value={weeklyHours}
placeholder={`weniger als ${stundenText}`} onChange={setWeeklyHours}
hint="Muss unter 38,5 liegen." placeholder={`weniger als ${stundenText}`}
/> hint="Muss unter 38,5 liegen."
/>
<SelectField
label="Grund der Reduktion"
value={reduktionsgrund}
onChange={setReduktionsgrund}
options={[
{ value: "", label: "Ohne besonderen Grund" },
...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })),
]}
hint="Steht danach in der Historie, wo auch die Stunden stehen."
/>
</>
)} )}
</> </>
)} )}

View File

@@ -7,18 +7,24 @@ import type { EmploymentStatus } from "./supabase/types";
// a label change (see supabase/migrations/*_absence_type.sql). So the value // 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. // 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 = [ export const ABSENCE_TYPES = [
"Wochenhilfe (Mutterschutz)", "Wochenhilfe (Mutterschutz)",
"Elternkarenz (inkl. Väterkarenz)", "Elternkarenz (inkl. Väterkarenz)",
"Papamonat", "Papamonat",
"Bildungskarenz", "Bildungskarenz",
"Bildungsteilzeit",
"Präsenzdienst", "Präsenzdienst",
"Zivildienst", "Zivildienst",
"Langer Krankenstand", "Langer Krankenstand",
"Wiedereingliederungsteilzeit",
"Pflegekarenz", "Pflegekarenz",
"Pflegeteilzeit",
"Familienhospizkarenz", "Familienhospizkarenz",
"Sabbatical", "Sabbatical",
] as const; ] as const;
@@ -29,6 +35,25 @@ export function isAbsenceType(value: string | null | undefined): value is Absenc
return ABSENCE_TYPES.includes(value as AbsenceType); 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. */ /** Display names for the stored status values. */
const STATUS_LABELS: Record<EmploymentStatus, string> = { const STATUS_LABELS: Record<EmploymentStatus, string> = {
Aktiv: "Aktiv", Aktiv: "Aktiv",

View File

@@ -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
$$;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 // The stored employment status is still 'Karenz' — renaming the enum value
// would mean rewriting every stored function that spells it. The rename // would mean rewriting every stored function that spells it. The rename
@@ -42,8 +42,10 @@ describe("absenceLabel", () => {
}); });
describe("ABSENCE_TYPES", () => { describe("ABSENCE_TYPES", () => {
it("holds the thirteen kinds, in the order they are offered", () => { it("holds the ten kinds, in the order they are offered", () => {
expect(ABSENCE_TYPES).toHaveLength(13); // 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[0]).toBe("Wochenhilfe (Mutterschutz)");
expect(ABSENCE_TYPES.at(-1)).toBe("Sabbatical"); expect(ABSENCE_TYPES.at(-1)).toBe("Sabbatical");
}); });
@@ -54,3 +56,47 @@ describe("ABSENCE_TYPES", () => {
expect(isAbsenceType(null)).toBe(false); 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);
});
});