diff --git a/actions/employees.ts b/actions/employees.ts index 4bac77b..1c85308 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -65,6 +65,14 @@ export async function hireEmployee(payload: { has_kuendigungsschutz?: boolean; /** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */ kuendigungsschutz_bis?: string | null; + /** Ebenfalls nur mit dem Kennzeichen — chk_kuendigungsschutz_grund. */ + kuendigungsschutz_grund?: string | null; + kuendigungsschutz_ab?: string | null; + ist_beguenstigt_behindert?: boolean; + /** Nur mit dem Kennzeichen — chk_behinderung. Als Text, wie er aus dem Feld kommt. */ + behinderung_grad?: string | null; + behinderung_ab?: string | null; + behinderung_bis?: string | null; }): Promise { // Einzige Mutation, deren Rückgabewert gebraucht wird: die neue // Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann. diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index 16a0329..af02e6b 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -5,6 +5,7 @@ import { fmtName, todayIso } from "@/lib/format"; import { subtreeOf } from "@/lib/org"; import { loadPlacements, loadReportingLineMap } from "@/lib/placement"; import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria"; +import { derivedStatusFilter } from "@/lib/employee-status-filter"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { requireHrUser } from "@/lib/auth/require-hr"; @@ -49,7 +50,15 @@ export async function GET(request: NextRequest) { function employeeQuery() { let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id"); if (filters.location) q = q.where("location_id", "=", filters.location); - if (!asOf) q = q.where("status", "in", statuses); + // Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert, + // mit Stichtag weiter unten über die Ableitung. Das sind zwei + // verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach, + // sobald ein Austritt mit einem damals künftigen Datum erfasst wurde + // (Migration 20260814100000 zieht sie nicht nach). Der Export nach + // „Ausgetreten" liess damit genau die Leute aus, die gerade + // ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL + // und einmal im Speicher. + if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true)); // Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die // Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein. return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA); @@ -150,7 +159,17 @@ function employeeExportColumns( { header: "Dienstwagen", get: (e) => e.has_dienstwagen }, { header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" }, { header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz }, + { header: "Personenkreis", get: (e) => e.kuendigungsschutz_grund ?? "" }, + { header: "Kündigungsschutz ab", get: (e) => e.kuendigungsschutz_ab, kind: "date" }, { header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" }, + // Der Bericht, den der Workshop verlangt hat (Anforderung 5a): wer ist + // begünstigt behindert, mit wieviel Prozent, ab und bis wann. Vier + // Spalten statt einer zusammengesetzten — in Excel wird danach gefiltert + // und summiert, und ein Text wie „50% seit 2019" liesse beides nicht zu. + { header: "Begünstigt behindert", get: (e) => e.ist_beguenstigt_behindert }, + { header: "Grad der Behinderung (%)", get: (e) => e.behinderung_grad ?? "" }, + { header: "Bescheid ab", get: (e) => e.behinderung_ab, kind: "date" }, + { header: "Bescheid bis", get: (e) => e.behinderung_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 ?? "" }, diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index 2338dd9..0da7253 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -1,6 +1,11 @@ "use client"; import { SelectField, TextField } from "@/components/ui/Field"; +import { + BEGUENSTIGUNG_AB_GRAD, + GRUND_BEGUENSTIGT_BEHINDERT, + KUENDIGUNGSSCHUTZ_GRUENDE, +} from "@/lib/kuendigungsschutz"; import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/types"; const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; @@ -30,6 +35,25 @@ export type RoleEmploymentValue = { * einer erfundenen Zahl. Leer heisst „bis auf Weiteres". */ kuendigungsschutzBis: string; + /** + * Der Personenkreis — freiwillig, aus demselben Grund wie das Enddatum: + * der Bestand trägt das Kennzeichen seit 20260814120000 ohne Grund, und ein + * Pflichtfeld hätte jede Änderung an diesen Zeilen blockiert. + */ + kuendigungsschutzGrund: string; + /** Beginn des Schutzes — ebenfalls freiwillig. */ + kuendigungsschutzAb: string; + /** + * Begünstigte Behinderung. Eigenes Kennzeichen und nicht aus dem + * Personenkreis abgeleitet: die Begünstigung besteht unabhängig davon, ob + * sie als Kündigungsschutz geführt wird. Die Oberfläche stellt den + * Zusammenhang her, die Datenbank koppelt die beiden nicht. + */ + istBeguenstigtBehindert: boolean; + /** Grad in Prozent laut Bescheid. Als Text, weil ein leeres Zahlenfeld sonst 0 wäre. */ + behinderungGrad: string; + behinderungAb: string; + behinderungBis: string; }; // Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit @@ -134,14 +158,79 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen gibt. Anders als dort aber freiwillig — leer heisst „bis auf Weiteres", nicht „vergessen". */} {value.hasKuendigungsschutz && ( - onChange({ kuendigungsschutzBis: v })} - hint="Optional. Leer lassen, solange das Ende nicht feststeht." + <> + onChange({ kuendigungsschutzGrund: v })} + options={[ + { value: "", label: "Nicht erfasst" }, + ...KUENDIGUNGSSCHUTZ_GRUENDE.map((g) => ({ value: g, label: g })), + ]} + hint="Bestimmt, wer einer Beendigung zustimmen muss." + /> + onChange({ kuendigungsschutzAb: v })} + hint="Optional." + /> + onChange({ kuendigungsschutzBis: v })} + hint="Optional. Leer lassen, solange das Ende nicht feststeht." + /> + + )} + + + {/* ── Begünstigte Behinderung ────────────────────────────────── + Eigener Block und nicht innerhalb des Kündigungsschutzes: die + Begünstigung besteht für sich, auch wenn niemand sie als Schutz + führt. Der Haken setzt sich mit, wenn der Personenkreis gewählt + wird — das ist eine Hilfe der Oberfläche, keine Bedingung der + Datenbank, und er lässt sich danach wieder abwählen. */} +
+ + {(value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT) && ( + <> + onChange({ behinderungGrad: v })} + hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`} + /> + onChange({ behinderungAb: v })} + /> + onChange({ behinderungBis: v })} + hint="Leer heisst unbefristet." + /> + )}
diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index 1fdfb4a..9566d2e 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -14,6 +14,7 @@ import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries"; import { STUNDEN_GRUENDE } from "@/lib/absence"; +import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz"; 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/types"; @@ -86,6 +87,14 @@ export function DatenAendernPanel({ hasDienstwagen: employee.has_dienstwagen ?? false, hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false, kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "", + kuendigungsschutzGrund: employee.kuendigungsschutz_grund ?? "", + kuendigungsschutzAb: employee.kuendigungsschutz_ab ?? "", + istBeguenstigtBehindert: employee.ist_beguenstigt_behindert ?? false, + // Als Text und nicht als Zahl: ein leeres Zahlenfeld wäre 0, und 0 + // bedeutet hier „kein Bescheid", nicht „Grad null". + behinderungGrad: employee.behinderung_grad === null ? "" : String(employee.behinderung_grad), + behinderungAb: employee.behinderung_ab ?? "", + behinderungBis: employee.behinderung_bis ?? "", dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner", isLateraleFuehrung: employee.is_laterale_fuehrung ?? false, isCLevel: employee.is_c_level ?? false, @@ -93,6 +102,10 @@ export function DatenAendernPanel({ function updateRole(patch: Partial) { setRole((prev) => ({ ...prev, ...patch })); } + // Dieselbe Regel wie in RoleEmploymentFields: der Personenkreis zieht das + // Kennzeichen mit. Hier noch einmal, weil nicht das Formular speichert, + // sondern diese Seite — und was gezeigt wird, muss auch abgeschickt werden. + const istBehindert = role.istBeguenstigtBehindert || role.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT; function handleEmploymentTypeChange(value: EmploymentType) { setEmploymentType(value); @@ -165,7 +178,16 @@ export function DatenAendernPanel({ has_dienstwagen: role.hasDienstwagen, dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "", has_kuendigungsschutz: role.hasKuendigungsschutz, + // Leer statt des Wertes, wenn das Kennzeichen weg ist: die Bedingung + // in der Datenbank duldet keinen Rest ohne Bezug, und die Funktion + // räumt ihn auf demselben Weg weg wie beim Dienstwagen. kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "", + kuendigungsschutz_grund: role.hasKuendigungsschutz ? role.kuendigungsschutzGrund : "", + kuendigungsschutz_ab: role.hasKuendigungsschutz ? role.kuendigungsschutzAb : "", + ist_beguenstigt_behindert: istBehindert, + behinderung_grad: istBehindert ? role.behinderungGrad : "", + behinderung_ab: istBehindert ? role.behinderungAb : "", + behinderung_bis: istBehindert ? role.behinderungBis : "", // 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 diff --git a/components/hire/HireWizard.tsx b/components/hire/HireWizard.tsx index fb30456..8c06001 100644 --- a/components/hire/HireWizard.tsx +++ b/components/hire/HireWizard.tsx @@ -7,6 +7,7 @@ import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts"; import { Button } from "@/components/ui/Button"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; +import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz"; import type { OpenPositionResolved } from "@/lib/positions"; import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr"; import { StepAngehoerige } from "./StepAngehoerige"; @@ -116,6 +117,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf async function handleSubmit() { if (!selectedPosition || !draft.besetzung) return; + // Dieselbe Regel wie im Formular (RoleEmploymentFields): der + // Personenkreis zieht das Kennzeichen mit. Was gezeigt wurde, muss auch + // abgeschickt werden. + const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT; setSubmitting(true); const result = await hireEmployee({ personnel_number: Number(draft.personnelNumber), @@ -146,8 +151,15 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf // sonst nicht zu. dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null, has_kuendigungsschutz: draft.hasKuendigungsschutz, - // Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu. + // Ohne Schutz kein Enddatum, kein Beginn, kein Personenkreis — die + // CHECKs lassen es nicht anders zu. kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null, + kuendigungsschutz_grund: draft.hasKuendigungsschutz ? draft.kuendigungsschutzGrund || null : null, + kuendigungsschutz_ab: draft.hasKuendigungsschutz ? draft.kuendigungsschutzAb || null : null, + ist_beguenstigt_behindert: istBehindert, + behinderung_grad: istBehindert ? draft.behinderungGrad || null : null, + behinderung_ab: istBehindert ? draft.behinderungAb || null : null, + behinderung_bis: istBehindert ? draft.behinderungBis || null : null, emergency_contact_name: draft.emergencyContactName.trim() || undefined, emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined, emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined, diff --git a/components/hire/types.ts b/components/hire/types.ts index 2005f8c..221590f 100644 --- a/components/hire/types.ts +++ b/components/hire/types.ts @@ -59,6 +59,14 @@ export type HireDraftData = { hasKuendigungsschutz: boolean; /** Freiwillig — leer heisst „bis auf Weiteres". */ kuendigungsschutzBis: string; + /** Personenkreis nach dem besonderen Kündigungsschutz; leer heisst „nicht erfasst". */ + kuendigungsschutzGrund: string; + kuendigungsschutzAb: string; + istBeguenstigtBehindert: boolean; + /** Grad in Prozent als Text — ein leeres Zahlenfeld wäre sonst 0. */ + behinderungGrad: string; + behinderungAb: string; + behinderungBis: string; }; export const EMPTY_HIRE_DRAFT: HireDraftData = { @@ -95,4 +103,10 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = { isCLevel: false, hasKuendigungsschutz: false, kuendigungsschutzBis: "", + kuendigungsschutzGrund: "", + kuendigungsschutzAb: "", + istBeguenstigtBehindert: false, + behinderungGrad: "", + behinderungAb: "", + behinderungBis: "", }; diff --git a/db/migrations/20260915120000_kuendigungsschutz_personenkreis_und_behinderung.sql b/db/migrations/20260915120000_kuendigungsschutz_personenkreis_und_behinderung.sql new file mode 100644 index 0000000..cea130d --- /dev/null +++ b/db/migrations/20260915120000_kuendigungsschutz_personenkreis_und_behinderung.sql @@ -0,0 +1,766 @@ +-- Besonderer Kündigungsschutz: welcher Personenkreis, und seit wann. +-- Dazu die begünstigte Behinderung als eigener Befund. +-- +-- Anforderungen 5 und 5a aus dem Workshop. +-- +-- ═══ Was vorher da war und warum es nicht reichte ═══ +-- +-- Seit Migration 20260814120000 gibt es ein Kennzeichen und ein Enddatum. Das +-- beantwortet „darf hier ohne Weiteres beendet werden?" — mehr nicht. Die +-- Personalabteilung braucht aber die zweite Frage mit: *warum* jemand +-- geschützt ist. Davon hängt ab, wer zustimmen muss (Betriebsrat, Gericht, +-- Behindertenausschuss) und wie lange der Schutz nachwirkt. Ohne den Grund +-- musste man im Papierakt nachsehen — also genau dort, wo unter Zeitdruck +-- niemand nachsieht. +-- +-- ═══ Warum ein CHECK auf text und kein Aufzählungstyp ═══ +-- +-- Die Liste der Personenkreise ist Rechtslage, nicht Stammdatum: sie ändert +-- sich mit dem Gesetz. Ein Aufzählungstyp liesse sich zwar erweitern, aber +-- nicht ohne Weiteres umbenennen oder ausdünnen, und ein Wert, den es nicht +-- mehr geben soll, bliebe für immer im Typ stehen. Das Haus macht das bei +-- Abwesenheitsart, Antriebsart, Notizkategorie und Land schon so — siehe +-- CLAUDE.md-Konvention „Enum-Typen 10; ein Teil ist CHECK auf text". +-- +-- ═══ Warum die Behinderung eigene Spalten bekommt ═══ +-- +-- „Begünstigte behinderte ArbeitnehmerInnen" ist einer der Personenkreise, +-- aber der einzige, an dem weitere Angaben hängen: der Grad in Prozent und +-- der Zeitraum des Bescheids. Als Personenkreis allein liesse sich der +-- geforderte Bericht („begünstigt behindert mit wieviel Prozent, ab/bis +-- wann") nicht bauen. +-- +-- Die beiden sind trotzdem **nicht** aneinander gekettet. Eine Bedingung +-- „Grad nur, wenn Personenkreis = begünstigt behindert" hätte denselben +-- Nachteil wie jede Kopplung dieser Art: wer den Personenkreis korrigiert, +-- scheiterte an der Bedingung, solange der Grad noch dransteht — also genau +-- beim Geradebiegen eines Fehlers. Die Oberfläche stellt den Zusammenhang +-- her, indem sie die Felder zusammen zeigt; die Datenbank prüft nur, was +-- ohne sein Kennzeichen nicht dastehen darf. + +alter table employees + add column if not exists kuendigungsschutz_grund text, + add column if not exists kuendigungsschutz_ab date, + add column if not exists ist_beguenstigt_behindert boolean not null default false, + add column if not exists behinderung_grad integer, + add column if not exists behinderung_ab date, + add column if not exists behinderung_bis date; + +comment on column employees.kuendigungsschutz_grund is + 'Personenkreis nach dem besonderen Kündigungsschutz. Freiwillig: Altbestand trägt das Kennzeichen ohne Grund, und ein Pflichtfeld hätte diese Zeilen unänderbar gemacht.'; +comment on column employees.kuendigungsschutz_ab is + 'Beginn des Schutzes, falls bekannt. Gegenstück zu kuendigungsschutz_bis, das es seit 20260814120000 gibt.'; +comment on column employees.ist_beguenstigt_behindert is + 'Begünstigte:r Behinderte:r nach dem Behinderteneinstellungsgesetz. Eigenes Kennzeichen und nicht aus kuendigungsschutz_grund abgeleitet: die Begünstigung besteht unabhängig davon, ob sie als Kündigungsschutz geführt wird.'; +comment on column employees.behinderung_grad is + 'Grad der Behinderung in Prozent laut Bescheid. Die Begünstigung beginnt bei 50, erfasst wird aber jeder bescheidmässige Grad.'; +comment on column employees.behinderung_ab is 'Beginn laut Bescheid.'; +comment on column employees.behinderung_bis is 'Ende laut Bescheid, falls befristet. Leer heisst unbefristet oder nicht erfasst.'; + +-- Ohne Kennzeichen kein Grund und kein Beginn: beides wäre sonst ein Rest, +-- den niemand mehr deuten kann. has_kuendigungsschutz ist NOT NULL, deshalb +-- genügt die einfache Oder-Form. +alter table employees drop constraint if exists chk_kuendigungsschutz_grund; +alter table employees add constraint chk_kuendigungsschutz_grund + check (has_kuendigungsschutz or (kuendigungsschutz_grund is null and kuendigungsschutz_ab is null)); + +alter table employees drop constraint if exists chk_kuendigungsschutz_personenkreis; +alter table employees add constraint chk_kuendigungsschutz_personenkreis + check (kuendigungsschutz_grund is null or kuendigungsschutz_grund in ( + 'Eltern', + 'Schwangerschaft', + 'Karenzurlaub', + 'Familienzeit', + 'Elternteilzeit', + 'Betriebsräte & Ersatzmitglieder', + 'Jugendvertrauenspersonen', + 'Behindertenvertrauenspersonen & Stellvertreter', + 'Präsenz-, Ausbildungs- oder Zivildienst', + 'Begünstigte behinderte ArbeitnehmerInnen', + 'Familienhospizkarenz', + 'Familienhospizteilzeit' + )); + +comment on constraint chk_kuendigungsschutz_personenkreis on employees is + 'Die Personenkreise des besonderen Kündigungsschutzes. Als CHECK auf text und nicht als Aufzählungstyp: die Liste ist Rechtslage und ändert sich mit dem Gesetz — ein Typ liesse einen zurückgenommenen Wert für immer stehen. Die Oberfläche liest dieselbe Liste aus lib/kuendigungsschutz.ts; beide müssen zusammenpassen.'; + +-- Der Zeitraum muss ein Zeitraum sein. „Bis vor ab" ist kein Grenzfall, +-- sondern ein Tippfehler, und ohne diese Bedingung stünde er in jeder +-- Auswertung. +alter table employees drop constraint if exists chk_kuendigungsschutz_zeitraum; +alter table employees add constraint chk_kuendigungsschutz_zeitraum + check (kuendigungsschutz_ab is null or kuendigungsschutz_bis is null or kuendigungsschutz_ab <= kuendigungsschutz_bis); + +alter table employees drop constraint if exists chk_behinderung; +alter table employees add constraint chk_behinderung + check ( + ist_beguenstigt_behindert + or (behinderung_grad is null and behinderung_ab is null and behinderung_bis is null) + ); + +alter table employees drop constraint if exists chk_behinderung_grad; +alter table employees add constraint chk_behinderung_grad + check (behinderung_grad is null or (behinderung_grad between 1 and 100)); + +comment on constraint chk_behinderung_grad on employees is + 'Ein Grad von 0 ist kein erfasster Befund, sondern ein leeres Feld mit einer Zahl darin — dafür ist null da. Über 100 gibt es nicht.'; + +alter table employees drop constraint if exists chk_behinderung_zeitraum; +alter table employees add constraint chk_behinderung_zeitraum + check (behinderung_ab is null or behinderung_bis is null or behinderung_ab <= behinderung_bis); + +-- Für den geforderten Bericht: er fragt nach den Begünstigten, und das sind +-- wenige. Ein Teilindex kostet fast nichts und spart den vollen Durchlauf. +create index if not exists idx_employees_beguenstigt_behindert + on employees (behinderung_grad) where ist_beguenstigt_behindert; + +create index if not exists idx_employees_kuendigungsschutz_grund + on employees (kuendigungsschutz_grund) where kuendigungsschutz_grund 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'), + 'Aufenthaltstitel', jsonb_build_array('hat_aufenthaltstitel', 'boolean', 'person'), + 'Aufenthaltstitel bis', jsonb_build_array('aufenthaltstitel_bis', 'date', '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 Personenkreis', jsonb_build_array('kuendigungsschutz_grund', 'text', 'role'), + 'Kündigungsschutz ab', jsonb_build_array('kuendigungsschutz_ab', 'date', 'role'), + 'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'), + 'Begünstigt behindert', jsonb_build_array('ist_beguenstigt_behindert', 'boolean', 'role'), + -- 'integer' und nicht 'numeric': der Typ geht in update_history_entry + -- unmittelbar als Umwandlung ins SQL (`%L::%s`). 'numeric' liefe über + -- eine zweite, stillschweigende Umwandlung auf die Spalte und rundete + -- dabei — der Grad ist eine ganze Zahl aus einem Bescheid. + 'Grad der Behinderung', jsonb_build_array('behinderung_grad', 'integer', 'role'), + 'Behinderung ab', jsonb_build_array('behinderung_ab', 'date', 'role'), + 'Behinderung bis', jsonb_build_array('behinderung_bis', 'date', 'role'), + 'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'), + 'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'), + -- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu + -- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit + -- und Rückkehr ihre eigenen Vorgänge haben. + 'Status', jsonb_build_array('status', 'employment_status', null), + 'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null), + 'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null), + 'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null) + ); +$function$; + + +CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb) + RETURNS uuid + LANGUAGE plpgsql + SET search_path TO 'public', 'pg_temp' +AS $function$ +declare + v_id uuid; + v_position_id uuid := (payload->>'position_id')::uuid; + v_entry date := (payload->>'entry_date')::date; + v_besetzt uuid; + v_schutz boolean := coalesce((payload->>'has_kuendigungsschutz')::boolean, false); + v_behindert boolean := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, false); +begin + perform require_hr_admin(); + + if v_position_id is null then + raise exception 'Es muss eine Planstelle angegeben werden.'; + end if; + + if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then + raise exception 'Es muss eine Personalnummer angegeben werden.'; + end if; + if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then + raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number'; + end if; + + declare + v_ab date; + v_bis date; + begin + select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id; + if v_ab is null then + raise exception 'Die Planstelle existiert nicht.'; + end if; + if v_entry < v_ab then + raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry; + end if; + if v_bis is not null and v_entry >= v_bis then + raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry; + end if; + end; + + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_position_id + and (pa.valid_to is null or pa.valid_to > v_entry); + if v_besetzt is not null then + raise exception 'Diese Planstelle ist bereits besetzt.'; + end if; + + insert into employees ( + personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone, + address, postal_code, city, address_country, location_id, job_title, + employment_type, weekly_hours, contract_type, contract_end_date, paygrade, + source, status, entry_date, title_prefix, title_suffix, + worker_type, collective_agreement, work_days, + is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level, + has_kuendigungsschutz, kuendigungsschutz_grund, kuendigungsschutz_ab, kuendigungsschutz_bis, + ist_beguenstigt_behindert, behinderung_grad, behinderung_ab, behinderung_bis, + hat_aufenthaltstitel, aufenthaltstitel_bis, + dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation + ) + values ( + (payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type, + (payload->>'birth_date')::date, payload->>'sv_nummer', + coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone', + payload->>'address', payload->>'postal_code', payload->>'city', + coalesce(payload->>'address_country', 'Österreich'), + (payload->>'location_id')::uuid, + (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id), + coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'), + coalesce((payload->>'weekly_hours')::numeric, 38.5), + coalesce((payload->>'contract_type')::contract_type, 'unbefristet'), + nullif(payload->>'contract_end_date', '')::date, + coalesce((payload->>'paygrade')::paygrade_type, 'B'), + coalesce((payload->>'source')::source_type, 'Extern'), + case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status, + v_entry, + coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'), + coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'), + coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'), + coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'), + coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'), + coalesce((payload->>'is_betriebsrat')::boolean, false), + coalesce((payload->>'has_dienstwagen')::boolean, false), + coalesce((payload->>'is_laterale_fuehrung')::boolean, false), + coalesce((payload->>'is_c_level')::boolean, false), + v_schutz, + -- Grund, Beginn und Ende nur, wenn der Schutz überhaupt gesetzt ist: + -- sonst bliebe ein Rest ohne Bezug stehen, und die Bedingungen wiesen + -- den ganzen Vorgang zurück. + case when v_schutz then nullif(payload->>'kuendigungsschutz_grund', '') else null end, + case when v_schutz then nullif(payload->>'kuendigungsschutz_ab', '')::date else null end, + case when v_schutz then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end, + v_behindert, + case when v_behindert then nullif(payload->>'behinderung_grad', '')::integer else null end, + case when v_behindert then nullif(payload->>'behinderung_ab', '')::date else null end, + case when v_behindert then nullif(payload->>'behinderung_bis', '')::date else null end, + coalesce((payload->>'hat_aufenthaltstitel')::boolean, false), + case when coalesce((payload->>'hat_aufenthaltstitel')::boolean, false) + then nullif(payload->>'aufenthaltstitel_bis', '')::date else null end, + case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end, + nullif(payload->>'emergency_contact_name', ''), + nullif(payload->>'emergency_contact_phone', ''), + nullif(payload->>'emergency_contact_relation', '') + ) + returning id into v_id; + + insert into position_assignments (position_id, employee_id, valid_from) + values (v_position_id, v_id, v_entry); + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' || + (select position_number from om_positions where id = v_position_id)); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Neueinstellung', + (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry); + + return v_id; +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[]; + 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 ? 'hat_aufenthaltstitel' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel', v_old.hat_aufenthaltstitel::text, v_person->>'hat_aufenthaltstitel'); end if; + if v_person ? 'aufenthaltstitel_bis' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel bis', v_old.aufenthaltstitel_bis::text, (nullif(v_person->>'aufenthaltstitel_bis','')::date)::text); 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_grund' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz Personenkreis', v_old.kuendigungsschutz_grund, nullif(v_role->>'kuendigungsschutz_grund', '')); end if; + if v_role ? 'kuendigungsschutz_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz ab', v_old.kuendigungsschutz_ab::text, (nullif(v_role->>'kuendigungsschutz_ab','')::date)::text); 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 ? 'ist_beguenstigt_behindert' then v_contract_changes := app_aenderung(v_contract_changes, 'Begünstigt behindert', v_old.ist_beguenstigt_behindert::text, v_role->>'ist_beguenstigt_behindert'); end if; + if v_role ? 'behinderung_grad' then v_contract_changes := app_aenderung(v_contract_changes, 'Grad der Behinderung', v_old.behinderung_grad::text, (nullif(v_role->>'behinderung_grad','')::integer)::text); end if; + if v_role ? 'behinderung_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung ab', v_old.behinderung_ab::text, (nullif(v_role->>'behinderung_ab','')::date)::text); end if; + if v_role ? 'behinderung_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung bis', v_old.behinderung_bis::text, (nullif(v_role->>'behinderung_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, + hat_aufenthaltstitel = coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel), + -- Fällt der Titel weg, fällt das Datum mit. Sonst bliebe ein + -- Enddatum ohne Titel stehen, und chk_aufenthaltstitel_bis liesse + -- die ganze Änderung scheitern statt das Offensichtliche zu tun. + aufenthaltstitel_bis = case + when coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then + case when v_person ? 'aufenthaltstitel_bis' + then nullif(v_person->>'aufenthaltstitel_bis','')::date + else aufenthaltstitel_bis end + else null + 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), + 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, + -- Fällt der Schutz weg, fallen Grund und beide Daten mit. Andernfalls + -- bliebe ein Rest ohne Bezug stehen — die Bedingungen verbieten das, + -- und der Vorgang schlüge fehl, statt das Offensichtliche zu tun. + kuendigungsschutz_grund = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_grund' + then nullif(v_role->>'kuendigungsschutz_grund','') + else kuendigungsschutz_grund end + else null + end, + kuendigungsschutz_ab = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_ab' + then nullif(v_role->>'kuendigungsschutz_ab','')::date + else kuendigungsschutz_ab end + else null + 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, + ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert), + behinderung_grad = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end + else null + end, + behinderung_ab = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end + else null + end, + behinderung_bis = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_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$; + + +-- ═══ Der Nachtlauf holt nach, was er bisher liegenliess ═══ +-- +-- Eine auf später datierte Änderung landet als 'contract_change' in +-- pending_org_changes; apply_due_pending_changes schreibt sie am Stichtag in +-- die Zeile. Bisher schrieb es dabei **nur** Person und Vertrag zurück — die +-- ganze Gruppe `role` fehlte. Betriebsrat, Dienstwagen, Kollektivvertrag, +-- Arbeitstage, Teilzeitvariante und der Kündigungsschutz wurden also erfasst, +-- in der Historie vermerkt, als Vertragsänderung protokolliert — und am +-- Stichtag nicht angewendet. Sichtbar wurde das nie: der Eintrag in der Akte +-- steht da, nur die Spalte blieb alt. +-- +-- Das ist älter als diese Migration und gehört streng genommen nicht zu ihr. +-- Die neuen Felder erben den Fehler aber unmittelbar, und ihn nur für sie zu +-- beheben hiesse, zwei Klassen von Feldern mit zwei Verhaltensweisen zu +-- hinterlassen. Deshalb hier vollständig. +-- +-- Die Reihenfolge der Zuweisungen entspricht der in change_employee_data; +-- dieselben Kopplungen (fällt das Kennzeichen, fällt das Datum) gelten hier +-- genauso, sonst wiese eine Bedingung den Nachtlauf zurück. +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 integer := 0; + v_role jsonb; +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 employees set job_title = coalesce(v_rec.payload->>'new_title', job_title) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'promotion' then + update employees set + job_title = coalesce(v_rec.payload->>'new_title', job_title), + paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_start' then + update employees set + status = 'Karenz', + karenz_return_date = (v_rec.payload->>'planned_return_date')::date, + absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_return' then + 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. + 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 + v_role := v_rec.payload->'role'; + update employees set + first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name), + last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name), + gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender), + birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date), + sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer), + nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality), + address = coalesce(v_rec.payload->'person'->>'address', address), + postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code), + city = coalesce(v_rec.payload->'person'->>'city', city), + address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country), + email = coalesce(v_rec.payload->'person'->>'email', email), + phone = coalesce(v_rec.payload->'person'->>'phone', phone), + employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours), + contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type), + contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date' + then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date + else contract_end_date end, + worker_type = coalesce((v_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 coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}') + 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), + 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, + teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end, + 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, + hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel), + aufenthaltstitel_bis = case + when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then + case when v_rec.payload->'person' ? 'aufenthaltstitel_bis' + then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date + else aufenthaltstitel_bis end + else null + end, + has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz), + kuendigungsschutz_grund = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end + else null + end, + kuendigungsschutz_ab = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end + else null + 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, + ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert), + behinderung_grad = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end + else null + end, + behinderung_ab = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end + else null + end, + behinderung_bis = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end + else null + end + 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$; + +grant execute on function public.apply_due_pending_changes() to alpenwerk_app; + + +-- Selbstprüfung. +do $$ +declare + v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure); + v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure); + v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure); + v_spalte text; +begin + foreach v_spalte in array array[ + 'kuendigungsschutz_grund', 'kuendigungsschutz_ab', + 'ist_beguenstigt_behindert', 'behinderung_grad', 'behinderung_ab', 'behinderung_bis' + ] loop + if not exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'employees' and column_name = v_spalte + ) then + raise exception 'Die Spalte % fehlt', v_spalte; + end if; + if v_hire not like '%' || v_spalte || '%' then + raise exception 'hire_employee nimmt % nicht entgegen', v_spalte; + end if; + if v_chg not like '%' || v_spalte || '%' then + raise exception 'change_employee_data schreibt % nicht', v_spalte; + end if; + if v_lauf not like '%' || v_spalte || '%' then + raise exception 'Der Nachtlauf wendet % nicht an', v_spalte; + end if; + end loop; + + -- Die Feldtabelle: ohne sie liesse sich ein Historieneintrag zu diesen + -- Feldern nicht berichtigen (delete_/update_history_entry lesen sie). + foreach v_spalte in array array[ + 'Kündigungsschutz Personenkreis', 'Kündigungsschutz ab', + 'Begünstigt behindert', 'Grad der Behinderung', 'Behinderung ab', 'Behinderung bis' + ] loop + if not (app_feld_karte() ? v_spalte) then + raise exception 'Die Feldtabelle kennt % nicht', v_spalte; + end if; + if app_feld_karte()->v_spalte->>2 <> 'role' then + raise exception 'Die Gruppe im payload stimmt bei % nicht', v_spalte; + end if; + end loop; + + -- Der Nachtlauf holt jetzt auch die übrigen Rollenfelder nach. Ohne diese + -- Prüfung fiele ein späteres „create or replace" stillschweigend wieder + -- auf die alte, unvollständige Fassung zurück. + if v_lauf not like '%is_betriebsrat%' or v_lauf not like '%collective_agreement%' then + raise exception 'Der Nachtlauf wendet die Rollenfelder nicht an'; + end if; + + -- Die Bedingungen selbst. Geprüft wird ihre Definition und nicht ein + -- Probeschreiben: diese Migration muss auch auf einer **leeren** Datenbank + -- durchlaufen (CI-Job „migrationen"), und dort gäbe es keine Zeile, an der + -- sich eine Verletzung auslösen liesse — der Versuch ginge folgenlos aus + -- und die Prüfung meldete fälschlich einen Fehler. + if not exists ( + select 1 from pg_constraint + where conname = 'chk_kuendigungsschutz_personenkreis' + and pg_get_constraintdef(oid) like '%Familienhospizteilzeit%' + and pg_get_constraintdef(oid) like '%Jugendvertrauenspersonen%' + ) then + raise exception 'Die Bedingung auf den Personenkreis fehlt oder kennt die Liste nicht'; + end if; + + foreach v_spalte in array array[ + 'chk_kuendigungsschutz_grund', 'chk_kuendigungsschutz_zeitraum', + 'chk_behinderung', 'chk_behinderung_grad', 'chk_behinderung_zeitraum' + ] loop + if not exists (select 1 from pg_constraint where conname = v_spalte) then + raise exception 'Die Bedingung % fehlt', v_spalte; + end if; + end loop; +end +$$; diff --git a/lib/kuendigungsschutz.ts b/lib/kuendigungsschutz.ts new file mode 100644 index 0000000..b9f4560 --- /dev/null +++ b/lib/kuendigungsschutz.ts @@ -0,0 +1,57 @@ +import type { KuendigungsschutzGrund } from "./types"; + +// Die Personenkreise des besonderen Kündigungsschutzes — für die Auswahl in +// der Oberfläche und für den Bericht. +// +// Die Liste steht zweimal: hier und als CHECK +// chk_kuendigungsschutz_personenkreis in Migration 20260915120000. Das ist +// eine bewusste Doppelung mit einer Begründung, denn die Alternativen sind +// schlechter: ein Aufzählungstyp liesse einen zurückgenommenen Wert für immer +// im Typ stehen, und eine Tabelle machte aus einer Rechtslage ein Stammdatum, +// das jemand pflegen müsste. `npm run types:check` hält die Spalte gegen die +// Migration; dass die *Werte* zusammenpassen, sichert der Test in +// tests/unit/kuendigungsschutz.test.ts, der die Migration liest. +// +// Warum der Grund freiwillig ist: der Bestand trägt das Kennzeichen seit +// Migration 20260814120000 ohne Grund. Ein Pflichtfeld hätte diese Zeilen +// unänderbar gemacht — jede Änderung an einer davon wäre an einer Angabe +// gescheitert, die niemand mehr nachträgt. + +export const KUENDIGUNGSSCHUTZ_GRUENDE: readonly KuendigungsschutzGrund[] = [ + "Eltern", + "Schwangerschaft", + "Karenzurlaub", + "Familienzeit", + "Elternteilzeit", + "Betriebsräte & Ersatzmitglieder", + "Jugendvertrauenspersonen", + "Behindertenvertrauenspersonen & Stellvertreter", + "Präsenz-, Ausbildungs- oder Zivildienst", + "Begünstigte behinderte ArbeitnehmerInnen", + "Familienhospizkarenz", + "Familienhospizteilzeit", +] as const; + +/** + * Der Personenkreis, an dem die Angaben zur Behinderung hängen. + * + * Die Oberfläche blendet die Felder auf, sobald dieser Kreis gewählt wird. + * Eine Bedingung in der Datenbank ist das ausdrücklich nicht — siehe die + * Begründung in der Migration: wer den Personenkreis korrigiert, scheiterte + * sonst daran, solange der Grad noch dransteht. + */ +export const GRUND_BEGUENSTIGT_BEHINDERT: KuendigungsschutzGrund = "Begünstigte behinderte ArbeitnehmerInnen"; + +export function istKuendigungsschutzGrund(wert: string | null | undefined): wert is KuendigungsschutzGrund { + return KUENDIGUNGSSCHUTZ_GRUENDE.includes(wert as KuendigungsschutzGrund); +} + +/** + * Ob der Grad zur Begünstigung reicht. + * + * Die Grenze liegt bei 50 Prozent (§ 2 Behinderteneinstellungsgesetz). + * Erfasst wird trotzdem jeder bescheidmässige Grad: ein Bescheid über 30 + * Prozent ist eine Tatsache, auch wenn daraus kein besonderer Schutz folgt, + * und wer ihn nicht eintragen darf, trägt ihn in ein Freitextfeld ein. + */ +export const BEGUENSTIGUNG_AB_GRAD = 50; diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts index 7561c46..f6893f9 100644 --- a/lib/report-criteria.ts +++ b/lib/report-criteria.ts @@ -1,4 +1,6 @@ import { ABSENCE_TYPES } from "./absence"; +import { AUSTRITTSART_LABELS, BEENDIGUNGSART_WERTE, type Austrittsart } from "./beendigung"; +import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz"; import { parseIsoDateParam } from "./reports"; import type { Weekday } from "./types"; @@ -58,6 +60,22 @@ 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: "schutzGrund", + label: "Personenkreis Kündigungsschutz", + alle: "Alle Personenkreise", + optionen: werte(...KUENDIGUNGSSCHUTZ_GRUENDE), + }, + // Die Beendigungsart als Auswertungskriterium — für die Fluktuation nach + // Grund. Die Austrittsart (freiwillig/unfreiwillig) steht daneben und wird + // aus derselben Angabe abgeleitet, nicht zweitgespeichert. + { key: "beendigung", label: "Beendigungsart", alle: "Alle Beendigungsarten", optionen: werte(...BEENDIGUNGSART_WERTE) }, + { + key: "austrittsart", + label: "Austrittsart", + alle: "Alle Austrittsarten", + optionen: (Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ wert: a, label: AUSTRITTSART_LABELS[a] })), + }, { key: "teilzeitArt", label: "Teilzeitvariante", @@ -76,6 +94,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: "behindert", label: "Begünstigt behindert" }, { key: "teilzeit", label: "In einer Teilzeitvariante" }, { key: "aufenthaltstitel", label: "Aufenthaltstitel" }, { key: "dependents", label: "Angehörige erfasst" }, @@ -88,7 +107,10 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [ { key: "entry", label: "Eintritt" }, { key: "exit", label: "Austritt" }, { key: "birth", label: "Geburtsdatum" }, + { key: "schutzAb", label: "Kündigungsschutz ab" }, { key: "schutz", label: "Kündigungsschutz bis" }, + { key: "behinderungAb", label: "Bescheid Behinderung ab" }, + { key: "behinderungBis", label: "Bescheid Behinderung bis" }, { key: "teilzeitEnde", label: "Teilzeit endet" }, { key: "titelEnde", label: "Aufenthaltstitel läuft ab" }, ] as const; diff --git a/lib/reports-data.ts b/lib/reports-data.ts index 1b106c3..44392e8 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -3,6 +3,7 @@ import type { Schema } from "./db/schema"; import type { Tx } from "./db"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { loadPlacements } from "./placement"; +import { beendigungsartenZu, type Austrittsart } from "./beendigung"; import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria"; import { deriveStatusAsOf, @@ -22,6 +23,7 @@ import type { EmploymentType, GenderType, HistoryEventType, + KuendigungsschutzGrund, PaygradeType, SourceType, TeilzeitArt, @@ -67,6 +69,16 @@ export function applyCriteria( 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); + if (a.schutzGrund) q = q.where("kuendigungsschutz_grund", "=", a.schutzGrund as KuendigungsschutzGrund); + if (a.beendigung) q = q.where("exit_reason", "=", a.beendigung); + // Die Austrittsart ist keine Spalte, sondern eine Einteilung der + // Beendigungsarten (lib/beendigung.ts). Aus ihr wird deshalb eine + // Wertemenge statt eines Vergleichs — dieselbe Zuordnung, die auch das + // Formular verwendet, nur als `in`. + if (a.austrittsart) { + const werte = beendigungsartenZu(a.austrittsart as Austrittsart); + q = werte.length > 0 ? q.where("exit_reason", "in", werte) : q; + } const j = c.jaNein; if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja"); @@ -78,6 +90,7 @@ export function applyCriteria( if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null); if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null); if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "ja"); + if (j.behindert) q = q.where("ist_beguenstigt_behindert", "=", j.behindert === "ja"); // Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich // mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung. @@ -89,6 +102,12 @@ 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.schutzAb?.von) q = q.where("kuendigungsschutz_ab", ">=", c.zeitraum.schutzAb.von); + if (c.zeitraum.schutzAb?.bis) q = q.where("kuendigungsschutz_ab", "<=", c.zeitraum.schutzAb.bis); + if (c.zeitraum.behinderungAb?.von) q = q.where("behinderung_ab", ">=", c.zeitraum.behinderungAb.von); + if (c.zeitraum.behinderungAb?.bis) q = q.where("behinderung_ab", "<=", c.zeitraum.behinderungAb.bis); + if (c.zeitraum.behinderungBis?.von) q = q.where("behinderung_bis", ">=", c.zeitraum.behinderungBis.von); + if (c.zeitraum.behinderungBis?.bis) q = q.where("behinderung_bis", "<=", c.zeitraum.behinderungBis.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); // Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im diff --git a/lib/types.ts b/lib/types.ts index aa6fdc4..cc70f69 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -39,6 +39,29 @@ export type DienstwagenArt = "Verbrenner" | "Elektro"; */ export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit"; +/** + * Personenkreise des besonderen Kündigungsschutzes. + * + * Kein Aufzählungstyp in der Datenbank, sondern ein CHECK auf text + * (chk_kuendigungsschutz_personenkreis): die Liste ist Rechtslage und ändert + * sich mit dem Gesetz — ein Typ liesse einen zurückgenommenen Wert für immer + * stehen. Die Beschriftungen für die Oberfläche stehen in + * lib/kuendigungsschutz.ts und müssen zur Bedingung passen. + */ +export type KuendigungsschutzGrund = + | "Eltern" + | "Schwangerschaft" + | "Karenzurlaub" + | "Familienzeit" + | "Elternteilzeit" + | "Betriebsräte & Ersatzmitglieder" + | "Jugendvertrauenspersonen" + | "Behindertenvertrauenspersonen & Stellvertreter" + | "Präsenz-, Ausbildungs- oder Zivildienst" + | "Begünstigte behinderte ArbeitnehmerInnen" + | "Familienhospizkarenz" + | "Familienhospizteilzeit"; + /** * Verhältnis zum Notfallkontakt. * @@ -192,6 +215,21 @@ export type Database = { has_kuendigungsschutz: boolean; /** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */ kuendigungsschutz_bis: string | null; + /** + * Personenkreis nach dem besonderen Kündigungsschutz — chk_kuendigungsschutz_personenkreis. + * Freiwillig, denn der Altbestand trägt das Kennzeichen ohne Grund. + */ + kuendigungsschutz_grund: KuendigungsschutzGrund | null; + /** Beginn des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */ + kuendigungsschutz_ab: string | null; + /** Begünstigung nach dem Behinderteneinstellungsgesetz. */ + ist_beguenstigt_behindert: boolean; + /** Grad der Behinderung in Prozent laut Bescheid, 1..100 — chk_behinderung_grad. */ + behinderung_grad: number | null; + /** Beginn laut Bescheid. Nur mit dem Kennzeichen zusammen erlaubt. */ + behinderung_ab: string | null; + /** Ende laut Bescheid, falls befristet. Nur mit dem Kennzeichen zusammen erlaubt. */ + behinderung_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. */ @@ -247,6 +285,12 @@ export type Database = { dienstwagen_art?: DienstwagenArt | null; has_kuendigungsschutz?: boolean; kuendigungsschutz_bis?: string | null; + kuendigungsschutz_grund?: KuendigungsschutzGrund | null; + kuendigungsschutz_ab?: string | null; + ist_beguenstigt_behindert?: boolean; + behinderung_grad?: number | null; + behinderung_ab?: string | null; + behinderung_bis?: string | null; teilzeit_art?: TeilzeitArt | null; teilzeit_bis?: string | null; emergency_contact_name?: string | null; diff --git a/tests/components/RoleEmploymentFields.test.tsx b/tests/components/RoleEmploymentFields.test.tsx index b3d12fe..cc289a2 100644 --- a/tests/components/RoleEmploymentFields.test.tsx +++ b/tests/components/RoleEmploymentFields.test.tsx @@ -18,6 +18,12 @@ const WERT: RoleEmploymentValue = { isCLevel: false, hasKuendigungsschutz: false, kuendigungsschutzBis: "", + kuendigungsschutzGrund: "", + kuendigungsschutzAb: "", + istBeguenstigtBehindert: false, + behinderungGrad: "", + behinderungAb: "", + behinderungBis: "", }; function feld(teil: Partial = {}) { diff --git a/tests/unit/kuendigungsschutz.test.ts b/tests/unit/kuendigungsschutz.test.ts new file mode 100644 index 0000000..efdf3f6 --- /dev/null +++ b/tests/unit/kuendigungsschutz.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + BEGUENSTIGUNG_AB_GRAD, + GRUND_BEGUENSTIGT_BEHINDERT, + KUENDIGUNGSSCHUTZ_GRUENDE, + istKuendigungsschutzGrund, +} from "@/lib/kuendigungsschutz"; + +// Die Liste der Personenkreise steht zweimal: in lib/kuendigungsschutz.ts für +// die Oberfläche und als CHECK in der Migration für die Datenbank. Die +// Doppelung ist begründet (siehe dort) — aber eine begründete Doppelung ist +// immer noch eine, und zwei Listen laufen auseinander. Auffallen würde es +// erst beim Speichern: die Oberfläche böte einen Wert an, den die Bedingung +// zurückweist, und die Benutzerin sähe einen Fehler aus der Datenbank. +// +// Deshalb liest dieser Test die Migration und hält die beiden gegeneinander. +// Er braucht keine Datenbank — die Datei genügt. + +const MIGRATION = join( + process.cwd(), + "db/migrations/20260915120000_kuendigungsschutz_personenkreis_und_behinderung.sql" +); + +function werteAusDerBedingung(): string[] { + const sql = readFileSync(MIGRATION, "utf8"); + const block = sql.match(/chk_kuendigungsschutz_personenkreis\s+check[\s\S]*?\);/)?.[0]; + expect(block, "Die Bedingung steht nicht mehr in der Migration").toBeDefined(); + // Die Werte stehen als einfach zitierte Zeichenketten in der in-Liste. + return [...block!.matchAll(/'([^']+)'/g)].map((m) => m[1]); +} + +describe("Die Personenkreise", () => { + it("stimmen mit der Bedingung in der Datenbank überein", () => { + expect(new Set(werteAusDerBedingung())).toEqual(new Set(KUENDIGUNGSSCHUTZ_GRUENDE)); + }); + + it("stehen jeder nur einmal", () => { + expect(new Set(KUENDIGUNGSSCHUTZ_GRUENDE).size).toBe(KUENDIGUNGSSCHUTZ_GRUENDE.length); + }); + + it("enthalten den Kreis, an dem die Angaben zur Behinderung hängen", () => { + // Wird er umbenannt, blendet die Akte die Felder nicht mehr auf — und + // niemand bemerkt es, weil nichts fehlschlägt. + expect(KUENDIGUNGSSCHUTZ_GRUENDE).toContain(GRUND_BEGUENSTIGT_BEHINDERT); + }); + + it("weisen zurück, was nicht in der Liste steht", () => { + expect(istKuendigungsschutzGrund("Eltern")).toBe(true); + expect(istKuendigungsschutzGrund("Irgendetwas")).toBe(false); + expect(istKuendigungsschutzGrund("")).toBe(false); + expect(istKuendigungsschutzGrund(null)).toBe(false); + }); +}); + +describe("Die Grenze der Begünstigung", () => { + it("liegt bei 50 Prozent", () => { + expect(BEGUENSTIGUNG_AB_GRAD).toBe(50); + }); +});