diff --git a/actions/employees.ts b/actions/employees.ts index 1dca6bc..876298b 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -131,6 +131,8 @@ export async function recordKarenzReturn(payload: { weekly_hours?: number; /** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */ reduction_reason?: string; + /** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */ + teilzeit_bis?: string; }): Promise { return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); } @@ -141,12 +143,6 @@ 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/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index 03d0884..cc06491 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -149,6 +149,8 @@ function employeeExportColumns( { header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" }, { header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz }, { header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" }, + { header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" }, + { header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" }, { header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" }, { header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" }, { header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" }, diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index c661dbf..b81f484 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -70,6 +70,7 @@ export function DatenAendernPanel({ // gestellt hat. const [stundenGrund, setStundenGrund] = useState(STUNDEN_GRUENDE[0]); const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours); + const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? ""); const [contractType, setContractType] = useState(employee.contract_type); const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? ""); @@ -140,10 +141,6 @@ 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), @@ -159,6 +156,16 @@ export function DatenAendernPanel({ dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "", has_kuendigungsschutz: role.hasKuendigungsschutz, kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "", + // Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden + // tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert + // erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar + // zurück, obwohl niemand die Stunden angefasst hat. + ...(stundenGeaendert + ? { + teilzeit_art: stundenGrund === "Vertragliche Stundenänderung" ? "" : stundenGrund, + teilzeit_bis: stundenGrund === "Vertragliche Stundenänderung" ? "" : teilzeitBis, + } + : {}), is_laterale_fuehrung: role.isLateraleFuehrung, is_c_level: role.isCLevel, }, @@ -285,7 +292,17 @@ export function DatenAendernPanel({ value={stundenGrund} onChange={setStundenGrund} options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))} - hint="Steht danach in der Historie neben den Stunden." + hint="Steht danach am Profil und lässt sich auswerten." + /> + )} + {stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && ( + )} (""); + const [teilzeitBis, setTeilzeitBis] = useState(""); // Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie // hierzulande schreibt. const stundenText = String(employee.weekly_hours).replace(".", ","); @@ -116,6 +117,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos employment_mode: employmentMode, weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined, reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined, + teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined, }); setPending(false); if (result.success) { @@ -236,8 +238,17 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos { value: "", label: "Ohne besonderen Grund" }, ...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })), ]} - hint="Steht danach in der Historie, wo auch die Stunden stehen." + hint="Steht danach am Profil und lässt sich auswerten." /> + {reduktionsgrund && ( + + )} )} diff --git a/components/employees/tabs/VertragTab.tsx b/components/employees/tabs/VertragTab.tsx index 343b842..13cfcb5 100644 --- a/components/employees/tabs/VertragTab.tsx +++ b/components/employees/tabs/VertragTab.tsx @@ -37,6 +37,16 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) { ["C-Level", employee.is_c_level ? "Ja" : "Nein"], // Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der // Schutz noch läuft, und genau danach fragt man. + // Die Teilzeitvariante gehört neben die Stunden: sie erklärt, warum sie + // sind, wie sie sind. + [ + "Teilzeitvariante", + employee.teilzeit_art + ? employee.teilzeit_bis + ? `${employee.teilzeit_art} bis ${fmtDate(employee.teilzeit_bis)}` + : `${employee.teilzeit_art} (Ende offen)` + : "–", + ], [ "Besonderer Kündigungsschutz", employee.has_kuendigungsschutz diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts index cc33803..0fe130c 100644 --- a/lib/report-criteria.ts +++ b/lib/report-criteria.ts @@ -58,6 +58,12 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [ }, { key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") }, { key: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) }, + { + key: "teilzeitArt", + label: "Teilzeitvariante", + alle: "Alle Teilzeitvarianten", + optionen: werte("Bildungsteilzeit", "Elternteilzeit", "Pflegeteilzeit", "Wiedereingliederungsteilzeit"), + }, { key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") }, ] as const; @@ -70,6 +76,7 @@ export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [ { key: "lateral", label: "Laterale Führung" }, { key: "clevel", label: "C-Level" }, { key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" }, + { key: "teilzeit", label: "In einer Teilzeitvariante" }, { key: "dependents", label: "Angehörige erfasst" }, ] as const; @@ -81,6 +88,7 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [ { key: "exit", label: "Austritt" }, { key: "birth", label: "Geburtsdatum" }, { key: "schutz", label: "Kündigungsschutz bis" }, + { key: "teilzeitEnde", label: "Teilzeit endet" }, ] as const; export type Criteria = { diff --git a/lib/reports-data.ts b/lib/reports-data.ts index fc04a5f..fda67fb 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -23,6 +23,7 @@ import type { HistoryEventType, PaygradeType, SourceType, + TeilzeitArt, WorkerType, } from "./supabase/types"; @@ -63,6 +64,7 @@ export function applyCriteria( if (a.source) q = q.where("source", "=", a.source as SourceType); if (a.gender) q = q.where("gender", "=", a.gender as GenderType); if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt); + if (a.teilzeitArt) q = q.where("teilzeit_art", "=", a.teilzeitArt as TeilzeitArt); if (a.absence) q = q.where("absence_type", "=", a.absence); const j = c.jaNein; @@ -71,6 +73,9 @@ export function applyCriteria( if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja"); if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja"); if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja"); + // „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche. + if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null); + if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null); // Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich // mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung. @@ -82,6 +87,8 @@ export function applyCriteria( if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis); if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von); if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis); + if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von); + if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis); if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon); if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis); @@ -162,6 +169,7 @@ const SNAPSHOT_EMPLOYEE_COLUMNS = [ "has_dienstwagen", "is_laterale_fuehrung", "is_c_level", + "teilzeit_art", ] as const; // Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen — @@ -233,6 +241,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P has_dienstwagen: e.has_dienstwagen, is_laterale_fuehrung: e.is_laterale_fuehrung, is_c_level: e.is_c_level, + teilzeit_art: e.teilzeit_art, dependents_count: dependentsCounts.get(e.id) ?? 0, }); } diff --git a/lib/reports.ts b/lib/reports.ts index a7ce044..f00ebb7 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -25,6 +25,7 @@ export type GroupDimension = | "laterale_fuehrung" | "c_level" | "has_dependents" + | "teilzeit_art" | "weekday"; export const MEASURE_LABELS: Record = { @@ -55,6 +56,7 @@ export const GROUP_LABELS: Record = { laterale_fuehrung: "Laterale Führung", c_level: "C-Level", has_dependents: "Hat Angehörige", + teilzeit_art: "Teilzeitvariante", weekday: "Wochentag", }; @@ -101,6 +103,7 @@ export type ReportEmployee = { has_dienstwagen: boolean; is_laterale_fuehrung: boolean; is_c_level: boolean; + teilzeit_art: string | null; dependents_count: number; }; @@ -182,6 +185,9 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org return e.is_c_level ? "Ja" : "Nein"; case "has_dependents": return e.dependents_count > 0 ? "Ja" : "Nein"; + case "teilzeit_art": + // „Keine" statt „–": die Antwort ist hier eine Aussage, kein Fehlen. + return e.teilzeit_art ?? "Keine"; case "weekday": // Not a strict partition — see groupKeysFor, which aggregateReport // actually uses. This single-key fallback only covers a direct diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 55f8957..f751ea4 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -22,6 +22,15 @@ export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So"; */ export type DienstwagenArt = "Verbrenner" | "Elektro"; +/** + * Gesetzlich geregelte Teilzeiten. + * + * Keine Abwesenheiten: die Person arbeitet, nur kürzer. Als Zustand geführt, + * damit sich auswerten lässt, wer gerade in einer ist — mit einem optionalen + * Enddatum, weil nicht jede ein bekanntes Ende hat. + */ +export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit"; + /** * Verhältnis zum Notfallkontakt. * @@ -176,6 +185,10 @@ export type Database = { has_kuendigungsschutz: boolean; /** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */ kuendigungsschutz_bis: string | null; + /** Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen Regelung. */ + teilzeit_art: TeilzeitArt | null; + /** Ende der Teilzeit, falls bekannt. Nur mit einer Variante zusammen erlaubt. */ + teilzeit_bis: string | null; /** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */ emergency_contact_name: string | null; emergency_contact_phone: string | null; @@ -225,6 +238,8 @@ export type Database = { dienstwagen_art?: DienstwagenArt | null; has_kuendigungsschutz?: boolean; kuendigungsschutz_bis?: string | null; + teilzeit_art?: TeilzeitArt | null; + teilzeit_bis?: string | null; emergency_contact_name?: string | null; emergency_contact_phone?: string | null; emergency_contact_relation?: string | null; diff --git a/supabase/migrations/20260814160000_teilzeit_art.sql b/supabase/migrations/20260814160000_teilzeit_art.sql new file mode 100644 index 0000000..87b80b7 --- /dev/null +++ b/supabase/migrations/20260814160000_teilzeit_art.sql @@ -0,0 +1,350 @@ +-- Die Teilzeitvariante als Zustand, nicht nur als Notiz. +-- +-- Beim letzten Schritt sind die vier Teilzeiten aus der Abwesenheitsliste +-- gewandert und wurden mit der Änderung festgehalten — im Beschreibungstext +-- der Historie. Damit liess sich nachlesen, *dass* jemand in Bildungsteilzeit +-- ging, aber nicht auswerten, wer gerade in einer ist, und am Profil stand es +-- nirgends. +-- +-- Also ein richtiges Feld: teilzeit_art, dazu ein freiwilliges Enddatum. +-- +-- Der Einwand von damals — ein Zustand veraltet, weil niemand nachträgt, wann +-- eine Bildungsteilzeit endet — bleibt richtig und ist der Grund für +-- teilzeit_bis. Mit einem Enddatum kann eine Auswertung selbst entscheiden, +-- was noch läuft, statt sich auf gepflegte Daten zu verlassen. Bleibt das +-- Datum leer, heisst das „Ende offen", und das ist eine ehrliche Aussage. +-- +-- Geführt wird das Feld über den gewöhnlichen Weg der Änderungen: es steht in +-- app_feld_karte, taucht in der Historie als Feld mit Vorher/Nachher auf und +-- lässt sich dort berichtigen wie jedes andere. Der Anhang am +-- Beschreibungstext aus dem letzten Schritt entfällt dafür — zweimal +-- dasselbe zu schreiben lädt nur dazu ein, dass die zwei Fassungen +-- auseinanderlaufen. + +alter table employees + add column if not exists teilzeit_art text, + add column if not exists teilzeit_bis date; + +comment on column employees.teilzeit_art is + 'Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen vertraglichen Stundenregelung. Keine Abwesenheit — die Person arbeitet, nur kürzer.'; +comment on column employees.teilzeit_bis is + 'Ende der Teilzeit, falls bekannt. Freiwillig; leer heisst „Ende offen". Erlaubt Auswertungen darüber, was noch läuft, ohne auf nachgepflegte Daten angewiesen zu sein.'; + +alter table employees drop constraint if exists chk_teilzeit_art; +alter table employees add constraint chk_teilzeit_art + check (teilzeit_art is null or teilzeit_art in + ('Bildungsteilzeit', 'Elternteilzeit', 'Pflegeteilzeit', 'Wiedereingliederungsteilzeit')); + +alter table employees drop constraint if exists chk_teilzeit_bis; +alter table employees add constraint chk_teilzeit_bis + check (teilzeit_bis is null or teilzeit_art is not null); + +comment on constraint chk_teilzeit_bis on employees is + 'Ein Enddatum ohne Variante wäre ein Rest ohne Bezug. Umgekehrt ist eine Variante ohne Enddatum ausdrücklich erlaubt — nicht jede Teilzeit hat ein bekanntes Ende.'; + +create index if not exists idx_employees_teilzeit on employees (teilzeit_art) where teilzeit_art is not null; + +CREATE OR REPLACE FUNCTION public.app_feld_karte() + RETURNS jsonb + LANGUAGE sql + IMMUTABLE + SET search_path TO 'public', 'pg_temp' +AS $function$ + select jsonb_build_object( + 'Vorname', jsonb_build_array('first_name', 'text', 'person'), + 'Nachname', jsonb_build_array('last_name', 'text', 'person'), + 'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'), + 'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'), + 'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'), + 'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'), + 'Adresse', jsonb_build_array('address', 'text', 'person'), + 'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'), + 'Ort', jsonb_build_array('city', 'text', 'person'), + 'Land', jsonb_build_array('address_country', 'text', 'person'), + 'E-Mail', jsonb_build_array('email', 'text', 'person'), + 'Telefon', jsonb_build_array('phone', 'text', 'person'), + 'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'), + 'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'), + 'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'), + 'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'), + 'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'), + 'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'), + 'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'), + 'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'), + 'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'), + 'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'), + 'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'), + 'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'), + 'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'), + 'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'), + 'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'), + 'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'), + 'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'), + 'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'), + 'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'), + 'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'), + 'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role') + ); +$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[]; + -- Die Teilzeitvariante ist jetzt ein Feld an der Person (teilzeit_art) + -- und läuft über die gewöhnliche Änderungsliste. Der frühere Anhang am + -- Beschreibungstext ist damit weg — zweimal dasselbe zu schreiben lädt + -- nur dazu ein, dass die zwei Fassungen auseinanderlaufen. + 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 ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if; + if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_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. + teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end, + -- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und + -- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet. + teilzeit_bis = case + when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null + when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date + else teilzeit_bis + end, + 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, 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$; + +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), + -- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand, + -- kein Einmalereignis: danach lässt sich auswerten, wer gerade in + -- Eltern- oder Wiedereingliederungsteilzeit ist. + teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end, + teilzeit_bis = case + when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null + then nullif(payload->>'teilzeit_bis', '')::date + when payload->>'employment_mode' = 'Teilzeit' then null + else teilzeit_bis end + 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, + 'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', ''))); + 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$; + + +-- Selbstprüfung. +do $$ +declare + v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure); + v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure); +begin + if not (app_feld_karte() ? 'Teilzeitvariante' and app_feld_karte() ? 'Teilzeit bis') then + raise exception 'Die Feldtabelle kennt die Teilzeitvariante nicht'; + end if; + if v_chg not like '%teilzeit_art%' then + raise exception 'change_employee_data schreibt die Teilzeitvariante nicht'; + end if; + if v_chg like '%hours_reason%' then + raise exception 'Der alte Anhang am Beschreibungstext steht noch drin'; + end if; + if v_ret not like '%teilzeit_art%' then + raise exception 'record_karenz_return schreibt die Teilzeitvariante nicht'; + end if; +end +$$; diff --git a/supabase/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql b/supabase/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql new file mode 100644 index 0000000..6d6885c --- /dev/null +++ b/supabase/migrations/20260814170000_teilzeit_bei_geplanter_rueckkehr.sql @@ -0,0 +1,119 @@ +-- Die Teilzeitvariante übersteht auch eine geplante Rückkehr. +-- +-- record_karenz_return legt für ein Rückkehrdatum in der Zukunft eine Zeile +-- in pending_org_changes an; der Tageslauf wendet sie an. Diese Zeile trug +-- bisher nur Beschäftigungsausmaß und Stunden — die Variante wäre am Stichtag +-- verlorengegangen, und jemand käme in Elternteilzeit zurück, ohne dass es +-- irgendwo stünde ausser im Beschreibungstext der Historie. +-- +-- Aufgefallen beim Proben: die Probe hatte ein Rückkehrdatum in der Zukunft +-- gewählt und lief deshalb in genau diesen Zweig. + +CREATE OR REPLACE FUNCTION public.apply_due_pending_changes() + RETURNS integer + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ +declare + v_rec record; + v_count int := 0; +begin + for v_rec in + select * from pending_org_changes + where status = 'pending' and effective_date <= current_date + order by effective_date, created_at + loop + if v_rec.change_type = 'transfer' then + update position_assignments set valid_to = v_rec.effective_date + where employee_id = v_rec.employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date); + update employees set job_title = ( + select j.title from om_positions p join jobs j on j.id = p.job_id + where p.id = (v_rec.payload->>'target_position_id')::uuid + ) where id = v_rec.employee_id; + + elsif v_rec.change_type = 'promotion' then + update employees set + job_title = coalesce(v_rec.payload->>'new_title', job_title), + paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_start' then + update employees set + status = 'Karenz', + karenz_return_date = (v_rec.payload->>'planned_return_date')::date, + absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_return' then + update employees set + status = 'Aktiv', + karenz_return_date = null, + karenz_start_date = null, + absence_type = null, + employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours), + -- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am + -- Stichtag mit reduzierten Stunden zurück, und der Grund dafür + -- wäre verschwunden. Der Nachweis stünde nur in der Historie, + -- auswerten liesse sich nichts. + teilzeit_art = case when v_rec.payload ? 'teilzeit_art' + then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end, + teilzeit_bis = case when v_rec.payload ? 'teilzeit_art' + then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end + 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) + 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')::text, + nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date); + + elsif v_rec.change_type = 'dependent_remove' then + delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid; + end if; + + update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id; + v_count := v_count + 1; + end loop; + + return v_count; +end; +$function$; + + +-- Selbstprüfung. +do $$ +declare + v_apply text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure); + v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure); +begin + if v_apply not like '%teilzeit_art%' then + raise exception 'Der Tageslauf überträgt die Teilzeitvariante nicht'; + end if; + if v_ret not like '%teilzeit_art%' then + raise exception 'record_karenz_return legt die Variante nicht in den Vorgang'; + end if; +end +$$; diff --git a/tests/components/TerminatePanel.test.tsx b/tests/components/TerminatePanel.test.tsx index 0477f62..756d2ec 100644 --- a/tests/components/TerminatePanel.test.tsx +++ b/tests/components/TerminatePanel.test.tsx @@ -59,6 +59,8 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow { dienstwagen_art: null, has_kuendigungsschutz: false, kuendigungsschutz_bis: null, + teilzeit_art: null, + teilzeit_bis: null, is_laterale_fuehrung: false, is_c_level: false, title_prefix: [], diff --git a/tests/components/VertragTab.test.tsx b/tests/components/VertragTab.test.tsx index 1163381..b8d0a21 100644 --- a/tests/components/VertragTab.test.tsx +++ b/tests/components/VertragTab.test.tsx @@ -52,6 +52,8 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow { dienstwagen_art: null, has_kuendigungsschutz: false, kuendigungsschutz_bis: null, + teilzeit_art: null, + teilzeit_bis: null, is_laterale_fuehrung: false, is_c_level: false, title_prefix: [], @@ -123,3 +125,23 @@ describe("VertragTab — Kündigungsschutz", () => { expect(wertZu("Besonderer Kündigungsschutz")).toBe("Nein"); }); }); + +// Die Teilzeitvarianten sind keine Abwesenheiten, sondern ein Zustand mit +// weniger Stunden. Damit darüber berichtet werden kann, steht die Variante an +// der Person — und dann muss sie auch am Profil zu sehen sein. +describe("VertragTab — Teilzeitvariante", () => { + it("nennt Variante und Ende", () => { + render(); + expect(wertZu("Teilzeitvariante")).toBe("Bildungsteilzeit bis 30.06.2027"); + }); + + it("sagt es auch, wenn das Ende offen ist", () => { + render(); + expect(wertZu("Teilzeitvariante")).toBe("Elternteilzeit (Ende offen)"); + }); + + it("bleibt leer, wo keine besteht", () => { + render(); + expect(wertZu("Teilzeitvariante")).toBe("–"); + }); +}); diff --git a/tests/unit/reports.test.ts b/tests/unit/reports.test.ts index 04f7342..94801e4 100644 --- a/tests/unit/reports.test.ts +++ b/tests/unit/reports.test.ts @@ -37,6 +37,7 @@ function emp(overrides: Partial = {}): ReportEmployee { has_dienstwagen: false, is_laterale_fuehrung: false, is_c_level: false, + teilzeit_art: null, dependents_count: 0, ...overrides, }; @@ -310,3 +311,18 @@ describe("aggregateEvents", () => { expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" }); }); }); + +// Auswertbar sein war der Zweck: „wer ist gerade in Bildungsteilzeit" liess +// sich vorher nicht beantworten, weil die Variante nur im Text der Historie +// stand. +describe("Gruppierung nach Teilzeitvariante", () => { + it("gruppiert nach der Variante", () => { + expect(groupKeyFor(emp({ teilzeit_art: "Bildungsteilzeit" }), "teilzeit_art", lookups)).toBe("Bildungsteilzeit"); + expect(groupKeyFor(emp({ teilzeit_art: "Elternteilzeit" }), "teilzeit_art", lookups)).toBe("Elternteilzeit"); + }); + + it("nennt das Fehlen „Keine“ statt eines Gedankenstrichs", () => { + // In einer Auswertung ist „keine Teilzeit" eine Aussage, kein Fehlen. + expect(groupKeyFor(emp({ teilzeit_art: null }), "teilzeit_art", lookups)).toBe("Keine"); + }); +});