From 6681dcda779402f3e5fd2e9ffed5168e606356d7 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Fri, 14 Aug 2026 12:44:00 +0200 Subject: [PATCH] Flag people who cannot simply be dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Works council members, expectant mothers, parents on leave, registered disabled employees, apprentices — each has its own rules that come before a dismissal. The tool does not judge whether one is lawful, but it must not stay quiet about it either, and looking it up on the contract tab is exactly the step that gets skipped under time pressure. So: a checkbox, an optional end date, and a red warning at the top of the termination panel naming the date — or saying plainly that no end was recorded. It shows for a no-show too; the protection runs from the start of the contract, not the first day worked. The date is optional on purpose. A works council mandate has a known end, a pregnancy does not, and a mandatory field would force an invented number. A constraint says only what cannot be: an end date without the flag, which would be a leftover nobody could interpret. The field goes the whole way through — hire, data change, contract sheet, export, report criteria (as a yes/no and as a date range), and the import. A field that exists in one screen and not the next is how people stop trusting the numbers. Terminating is now offered for planned entries as well, labelled "Nicht angetreten", with No Show preselected. Without it a person who never turned up stayed a planned entry forever, since nothing else can end one. One finding worth recording: tsc has been reporting success on a broken program. A generated file under .next got corrupted when a build ran against a live dev server, and its syntax errors suppressed semantic checking everywhere else — two genuine type errors in this change went unreported until I typechecked with .next excluded. The file is removed and the ordinary typecheck is meaningful again. Co-Authored-By: Claude Opus 5 --- actions/employees.ts | 3 + app/api/export/employees/route.ts | 2 + components/employees/EmployeeDetail.tsx | 10 +- components/employees/RoleEmploymentFields.tsx | 31 +- .../employees/panels/DatenAendernPanel.tsx | 4 + .../employees/panels/TerminatePanel.tsx | 24 +- components/employees/tabs/VertragTab.tsx | 10 + components/hire/HireWizard.tsx | 3 + components/hire/StepSummary.tsx | 2 + components/hire/types.ts | 5 + lib/import/load.ts | 4 + lib/import/schema.ts | 16 + lib/report-criteria.ts | 2 + lib/reports-data.ts | 3 + lib/supabase/types.ts | 6 + .../20260814120000_kuendigungsschutz.sql | 357 ++++++++++++++++++ tests/components/TerminatePanel.test.tsx | 50 ++- tests/components/VertragTab.test.tsx | 21 ++ 18 files changed, 544 insertions(+), 9 deletions(-) create mode 100644 supabase/migrations/20260814120000_kuendigungsschutz.sql diff --git a/actions/employees.ts b/actions/employees.ts index a59f96f..f32137f 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -60,6 +60,9 @@ export async function hireEmployee(payload: { emergency_contact_relation?: string; is_laterale_fuehrung?: boolean; is_c_level?: boolean; + has_kuendigungsschutz?: boolean; + /** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */ + kuendigungsschutz_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 2e779d3..03d0884 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -147,6 +147,8 @@ function employeeExportColumns( { header: "Betriebsrat", get: (e) => e.is_betriebsrat }, { header: "Dienstwagen", get: (e) => e.has_dienstwagen }, { 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: "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/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index b574c04..4b72ad8 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -106,14 +106,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) { )} {canEditData && setPanel("daten")} />} - {isActive && ( + {/* Auch bei einem geplanten Eintritt, und dort heisst es anders: + wer nie angetreten ist, „tritt" nicht „aus". Die Person hat + noch keinen Tag gearbeitet, und genau dafür gibt es den Grund + „No Show" — ohne diesen Knopf bliebe sie auf Dauer als + geplanter Eintritt stehen. */} + {(isActive || employee.status === "Geplant") && ( )} {employee.status === "Ausgetreten" && ( diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index 3c77e17..f81ea8b 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -1,6 +1,6 @@ "use client"; -import { SelectField } from "@/components/ui/Field"; +import { SelectField, TextField } from "@/components/ui/Field"; import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types"; const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; @@ -22,6 +22,14 @@ export type RoleEmploymentValue = { dienstwagenArt: DienstwagenArt; isLateraleFuehrung: boolean; isCLevel: boolean; + /** Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehre. */ + hasKuendigungsschutz: boolean; + /** + * Ende des Schutzes — freiwillig. Bei einem Betriebsratsmandat steht es + * fest, bei einer Schwangerschaft nicht; ein Pflichtfeld zwänge dort zu + * einer erfundenen Zahl. Leer heisst „bis auf Weiteres". + */ + kuendigungsschutzBis: string; }; // Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit @@ -110,6 +118,27 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen onChange({ isCLevel: e.target.checked })} /> C-Level + + {/* Wie bei der Antriebsart: erst sichtbar, wenn es einen Gegenstand + 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." + /> + )} ); diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index b5b2f67..3cdaeb8 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -73,6 +73,8 @@ export function DatenAendernPanel({ workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"], isBetriebsrat: employee.is_betriebsrat ?? false, hasDienstwagen: employee.has_dienstwagen ?? false, + hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false, + kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "", dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner", isLateraleFuehrung: employee.is_laterale_fuehrung ?? false, isCLevel: employee.is_c_level ?? false, @@ -145,6 +147,8 @@ export function DatenAendernPanel({ is_betriebsrat: role.isBetriebsrat, has_dienstwagen: role.hasDienstwagen, dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "", + has_kuendigungsschutz: role.hasKuendigungsschutz, + kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "", is_laterale_fuehrung: role.isLateraleFuehrung, is_c_level: role.isCLevel, }, diff --git a/components/employees/panels/TerminatePanel.tsx b/components/employees/panels/TerminatePanel.tsx index ea3cc72..fb6822c 100644 --- a/components/employees/panels/TerminatePanel.tsx +++ b/components/employees/panels/TerminatePanel.tsx @@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import type { Database } from "@/lib/supabase/types"; -import { fmtName } from "@/lib/format"; +import { fmtDate, fmtName } from "@/lib/format"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; @@ -37,7 +37,10 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T const { showToast } = useToast(); const router = useRouter(); const [exitDate, setExitDate] = useState(""); - const [reason, setReason] = useState(EXIT_REASONS[0]); + // Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund + // aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die + // übrigen zu verstellen. + const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]); const [note, setNote] = useState(""); const [checked, setChecked] = useState(CHECKLIST_ITEMS.map(() => false)); const [pending, setPending] = useState(false); @@ -79,7 +82,7 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T @@ -93,6 +96,21 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T } >
+ {/* Zuerst, und nicht zu übersehen: bei besonderem Kündigungsschutz + gelten eigene Regeln, bevor beendet werden darf. Die Anwendung + entscheidet das nicht — sie darf es aber auch nicht verschweigen, + und im Vertragsblatt nachzusehen ist genau der Schritt, den man + unter Zeitdruck auslässt. */} + {employee.has_kuendigungsschutz && ( +
+ Achtung: besonderer Kündigungsschutz + {employee.kuendigungsschutz_bis ? ` bis ${fmtDate(employee.kuendigungsschutz_bis)}` : " (Ende nicht erfasst)"}. + + Vor einer Beendigung ist zu prüfen, ob sie zulässig ist — je nach Grund braucht es eine Zustimmung des + Betriebsrats oder des Gerichts. + +
+ )} {directReportCount > 0 && (
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet. diff --git a/components/employees/tabs/VertragTab.tsx b/components/employees/tabs/VertragTab.tsx index 84ce60e..343b842 100644 --- a/components/employees/tabs/VertragTab.tsx +++ b/components/employees/tabs/VertragTab.tsx @@ -35,6 +35,16 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) { ["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"], ["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"], ["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. + [ + "Besonderer Kündigungsschutz", + employee.has_kuendigungsschutz + ? employee.kuendigungsschutz_bis + ? `bis ${fmtDate(employee.kuendigungsschutz_bis)}` + : "Ja (Ende offen)" + : "Nein", + ], ]; if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]); diff --git a/components/hire/HireWizard.tsx b/components/hire/HireWizard.tsx index 820d613..299bdf8 100644 --- a/components/hire/HireWizard.tsx +++ b/components/hire/HireWizard.tsx @@ -130,6 +130,9 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf // Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe // 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. + kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || 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/StepSummary.tsx b/components/hire/StepSummary.tsx index 5d086f3..cf96d2c 100644 --- a/components/hire/StepSummary.tsx +++ b/components/hire/StepSummary.tsx @@ -25,6 +25,8 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`, draft.isLateraleFuehrung && "Laterale Führung", draft.isCLevel && "C-Level", + draft.hasKuendigungsschutz && + `Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`, ].filter(Boolean); const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone] .filter(Boolean) diff --git a/components/hire/types.ts b/components/hire/types.ts index 66e507d..52702f9 100644 --- a/components/hire/types.ts +++ b/components/hire/types.ts @@ -56,6 +56,9 @@ export type HireDraftData = { angehoerige: HireDraftAngehoerige[]; isLateraleFuehrung: boolean; isCLevel: boolean; + hasKuendigungsschutz: boolean; + /** Freiwillig — leer heisst „bis auf Weiteres". */ + kuendigungsschutzBis: string; }; export const EMPTY_HIRE_DRAFT: HireDraftData = { @@ -90,4 +93,6 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = { angehoerige: [], isLateraleFuehrung: false, isCLevel: false, + hasKuendigungsschutz: false, + kuendigungsschutzBis: "", }; diff --git a/lib/import/load.ts b/lib/import/load.ts index 5c87e7d..784adf5 100644 --- a/lib/import/load.ts +++ b/lib/import/load.ts @@ -237,6 +237,10 @@ export async function laden( emergency_contact_relation: txt(w.emergency_contact_relation), is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false), is_c_level: bool(w.is_c_level, false), + has_kuendigungsschutz: bool(w.has_kuendigungsschutz, false), + // Ohne Schutz kein Enddatum — chk_kuendigungsschutz_bis weist es sonst + // ab, und die ganze Datei scheiterte an einer einzelnen Zeile. + kuendigungsschutz_bis: bool(w.has_kuendigungsschutz, false) ? (txt(w.kuendigungsschutz_bis) ?? null) : null, entry_date: eintritt, exit_date: austritt, exit_reason: txt(w.exit_reason), diff --git a/lib/import/schema.ts b/lib/import/schema.ts index a82b7fe..7d5de00 100644 --- a/lib/import/schema.ts +++ b/lib/import/schema.ts @@ -391,6 +391,22 @@ export const BLATT_PERSONEN: BlattSchema = { }, { name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" }, { name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" }, + { + name: "Besonderer Kündigungsschutz", + ziel: "has_kuendigungsschutz", + pflicht: false, + typ: { art: "janein" }, + hinweis: "Leer = nein.", + beispiel: "nein", + }, + { + name: "Kündigungsschutz bis", + ziel: "kuendigungsschutz_bis", + pflicht: false, + typ: { art: "datum" }, + hinweis: "Nur mit gesetztem Schutz; freiwillig.", + beispiel: "", + }, { name: "Abwesenheit ab", ziel: "karenz_start_date", diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts index c94ea7c..cc33803 100644 --- a/lib/report-criteria.ts +++ b/lib/report-criteria.ts @@ -69,6 +69,7 @@ export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [ { key: "betriebsrat", label: "Betriebsrat" }, { key: "lateral", label: "Laterale Führung" }, { key: "clevel", label: "C-Level" }, + { key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" }, { key: "dependents", label: "Angehörige erfasst" }, ] as const; @@ -79,6 +80,7 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [ { key: "entry", label: "Eintritt" }, { key: "exit", label: "Austritt" }, { key: "birth", label: "Geburtsdatum" }, + { key: "schutz", label: "Kündigungsschutz bis" }, ] as const; export type Criteria = { diff --git a/lib/reports-data.ts b/lib/reports-data.ts index 66891ff..fc04a5f 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -70,6 +70,7 @@ export function applyCriteria( if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja"); 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"); // Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich // mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung. @@ -79,6 +80,8 @@ export function applyCriteria( if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis); if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von); 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.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon); if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis); diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 96b6ed7..55f8957 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -172,6 +172,10 @@ export type Database = { has_dienstwagen: boolean; /** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */ dienstwagen_art: DienstwagenArt | null; + /** Besonderer Kündigungsschutz — löst beim Austritt eine Warnung aus. */ + has_kuendigungsschutz: boolean; + /** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */ + kuendigungsschutz_bis: string | null; /** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */ emergency_contact_name: string | null; emergency_contact_phone: string | null; @@ -219,6 +223,8 @@ export type Database = { is_betriebsrat?: boolean; has_dienstwagen?: boolean; dienstwagen_art?: DienstwagenArt | null; + has_kuendigungsschutz?: boolean; + kuendigungsschutz_bis?: string | null; emergency_contact_name?: string | null; emergency_contact_phone?: string | null; emergency_contact_relation?: string | null; diff --git a/supabase/migrations/20260814120000_kuendigungsschutz.sql b/supabase/migrations/20260814120000_kuendigungsschutz.sql new file mode 100644 index 0000000..eeb6bb0 --- /dev/null +++ b/supabase/migrations/20260814120000_kuendigungsschutz.sql @@ -0,0 +1,357 @@ +-- Besonderer Kündigungsschutz. +-- +-- Betriebsratsmitglieder, Schwangere, Eltern in Karenz, begünstigte +-- Behinderte, Lehrlinge, Präsenzdiener — für sie gelten eigene Regeln, bevor +-- ein Dienstverhältnis beendet werden darf. Das Werkzeug entscheidet das +-- nicht, aber es soll niemanden einen Austritt erfassen lassen, ohne es zu +-- erwähnen. +-- +-- Zwei Spalten, wie beim Dienstwagen: ein Kennzeichen und eine Angabe, die +-- nur mit ihm zusammen Sinn ergibt. Das Datum ist **freiwillig** — bei einem +-- Betriebsratsmandat steht das Ende oft fest, bei einer Schwangerschaft +-- nicht, und ein Pflichtfeld zwänge dann zu einer erfundenen Zahl. +-- +-- Die Bedingung sagt nur, was ohne das Kennzeichen nicht sein darf. Sie +-- verlangt umgekehrt kein Datum. + +alter table employees + add column if not exists has_kuendigungsschutz boolean not null default false, + add column if not exists kuendigungsschutz_bis date; + +comment on column employees.has_kuendigungsschutz is + 'Besonderer Kündigungsschutz — Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehrverhältnis. Löst beim Austritt eine Warnung aus.'; +comment on column employees.kuendigungsschutz_bis is + 'Ende des Schutzes, falls bekannt. Freiwillig: bei einer Schwangerschaft steht es nicht fest, bei einem Mandat schon.'; + +alter table employees drop constraint if exists chk_kuendigungsschutz_bis; +alter table employees add constraint chk_kuendigungsschutz_bis + check (has_kuendigungsschutz or kuendigungsschutz_bis is null); + +comment on constraint chk_kuendigungsschutz_bis on employees is + 'Ein Enddatum ohne Schutz wäre ein Rest, den niemand mehr deuten kann. has_kuendigungsschutz ist NOT NULL, deshalb genügt hier die einfache Oder-Form — anders als bei chk_dienstwagen_art, wo eine nullbare Spalte die Bedingung sonst durchrutschen liesse.'; + +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') + ); +$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; +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_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), + coalesce((payload->>'has_kuendigungsschutz')::boolean, false), + -- Das Datum nur, wenn der Schutz überhaupt gesetzt ist: sonst bliebe + -- ein Enddatum ohne Schutz stehen, und chk_kuendigungsschutz_bis + -- würde es zu Recht abweisen. + case when coalesce((payload->>'has_kuendigungsschutz')::boolean, false) + then nullif(payload->>'kuendigungsschutz_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 ? 'title_prefix' then + v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}'); + v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)', + array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', ')); + end if; + if v_person ? 'title_suffix' then + v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}'); + v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)', + array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', ')); + end if; + + if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if; + -- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen. + if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if; + if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if; + if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if; + + if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if; + if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if; + if v_role ? 'work_days' then + v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}'); + v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage', + array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', ')); + end if; + if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if; + if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if; + if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if; + if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if; + if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if; + if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if; + if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if; + + if v_immediate then + update employees set + first_name = coalesce(v_person->>'first_name', first_name), + last_name = coalesce(v_person->>'last_name', last_name), + gender = coalesce((v_person->>'gender')::gender_type, gender), + birth_date = coalesce((v_person->>'birth_date')::date, birth_date), + sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer), + nationality = coalesce(v_person->>'nationality', nationality), + address = coalesce(v_person->>'address', address), + postal_code = coalesce(v_person->>'postal_code', postal_code), + city = coalesce(v_person->>'city', city), + address_country = coalesce(v_person->>'address_country', address_country), + email = coalesce(v_person->>'email', email), + phone = coalesce(v_person->>'phone', phone), + emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end, + emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end, + emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end, + title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end, + title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end, + employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours), + contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type), + contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end, + worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type), + collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement), + work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end, + is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat), + has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen), + is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung), + is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level), + has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz), + -- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein + -- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der + -- Vorgang schlüge fehl, statt das Offensichtliche zu tun. + kuendigungsschutz_bis = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_bis' + then nullif(v_role->>'kuendigungsschutz_bis','')::date + else kuendigungsschutz_bis end + else null + end, + dienstwagen_art = case + when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then + coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner') + else null + end + where id = v_employee_id; + elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'contract_change', v_effective_date, payload) + returning id into v_pending_id; + end if; + + if jsonb_array_length(v_person_changes) > 0 then + insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id) + values (v_employee_id, v_effective_date, 'Stammdatenänderung', + 'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id); + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes) + values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, + app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes); + end if; + + if jsonb_array_length(v_contract_changes) > 0 then + insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id) + values (v_employee_id, v_effective_date, 'Vertragsänderung', + 'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id); + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes) + values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, + app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes); + end if; +end; +$function$; + + +-- Selbstprüfung. +do $$ +declare + v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure); + v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure); +begin + if v_hire not like '%has_kuendigungsschutz%' then + raise exception 'hire_employee nimmt den Kündigungsschutz nicht entgegen'; + end if; + if v_chg not like '%Besonderer Kündigungsschutz%' then + raise exception 'change_employee_data protokolliert den Kündigungsschutz nicht'; + end if; + if not (app_feld_karte() ? 'Besonderer Kündigungsschutz' and app_feld_karte() ? 'Kündigungsschutz bis') then + raise exception 'Die Feldtabelle kennt den Kündigungsschutz nicht — dann liesse sich ein Eintrag dazu nicht berichtigen'; + end if; + if app_feld_karte()->'Kündigungsschutz bis'->>2 <> 'role' then + raise exception 'Die Gruppe im payload stimmt nicht'; + end if; +end +$$; diff --git a/tests/components/TerminatePanel.test.tsx b/tests/components/TerminatePanel.test.tsx index 0db93a6..0477f62 100644 --- a/tests/components/TerminatePanel.test.tsx +++ b/tests/components/TerminatePanel.test.tsx @@ -57,6 +57,8 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow { is_betriebsrat: false, has_dienstwagen: false, dienstwagen_art: null, + has_kuendigungsschutz: false, + kuendigungsschutz_bis: null, is_laterale_fuehrung: false, is_c_level: false, title_prefix: [], @@ -85,7 +87,7 @@ describe("TerminatePanel — Nichtantritt", () => { }); it("lässt das Datum bei einem gewöhnlichen Austritt frei", () => { - zeige(); + zeige({ status: "Aktiv" }); const feld = screen.getByLabelText(/Austrittsdatum/); expect(feld).toBeEnabled(); expect(feld).toHaveValue(""); @@ -104,7 +106,7 @@ describe("TerminatePanel — Nichtantritt", () => { it("schickt den Eintrittstag, auch wenn vorher ein anderes Datum dastand", async () => { const user = userEvent.setup(); const { terminateEmployee } = await import("@/actions/employees"); - zeige(); + zeige({ status: "Aktiv" }); // Erst ein Datum eintippen, dann auf No Show wechseln. await user.type(screen.getByLabelText(/Austrittsdatum/), "2026-12-31"); @@ -118,7 +120,7 @@ describe("TerminatePanel — Nichtantritt", () => { it("blendet die Offboarding-Checkliste aus — ausgegeben wurde nie etwas", async () => { const user = userEvent.setup(); - zeige(); + zeige({ status: "Aktiv" }); expect(screen.getByText("Offboarding-Checkliste")).toBeVisible(); await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show"); expect(screen.getByText("Offboarding-Checkliste")).not.toBeVisible(); @@ -131,3 +133,45 @@ describe("TerminatePanel — Nichtantritt", () => { expect(screen.getByText(/Planstelle wird wieder frei/)).toBeInTheDocument(); }); }); + +// Besonderer Kündigungsschutz: Betriebsrat, Mutterschutz, Karenz, begünstigte +// Behinderung, Lehre. Die Anwendung entscheidet nicht, ob eine Beendigung +// zulässig ist — sie darf es aber auch nicht verschweigen. Im Vertragsblatt +// nachzusehen ist genau der Schritt, den man unter Zeitdruck auslässt. +describe("TerminatePanel — Kündigungsschutz", () => { + it("warnt mit dem Enddatum, wenn eines erfasst ist", () => { + zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: "2027-06-30" }); + expect(screen.getByText(/Achtung: besonderer Kündigungsschutz bis 30\.06\.2027/)).toBeInTheDocument(); + }); + + it("warnt auch ohne Enddatum, und sagt dass keines erfasst ist", () => { + zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: null }); + expect(screen.getByText(/Ende nicht erfasst/)).toBeInTheDocument(); + }); + + it("schweigt, wo kein Schutz besteht", () => { + zeige(); + expect(screen.queryByText(/besonderer Kündigungsschutz/i)).not.toBeInTheDocument(); + }); + + it("warnt auch bei einem Nichtantritt — der Schutz gilt ab Vertragsbeginn", async () => { + const user = userEvent.setup(); + zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: "2027-06-30" }); + await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show"); + expect(screen.getByText(/Achtung: besonderer Kündigungsschutz/)).toBeInTheDocument(); + }); +}); + +describe("TerminatePanel — geplanter Eintritt", () => { + it("schlägt bei einem geplanten Eintritt No Show vor", () => { + zeige({ status: "Geplant" }); + expect(screen.getByLabelText("Beendigungsart")).toHaveValue("No Show"); + expect(screen.getByRole("heading", { name: "Nicht angetreten" })).toBeInTheDocument(); + }); + + it("bleibt bei einer aktiven Person beim gewöhnlichen Austritt", () => { + zeige({ status: "Aktiv" }); + expect(screen.getByLabelText("Beendigungsart")).toHaveValue("Einvernehmliche Auflösung"); + expect(screen.getByRole("heading", { name: "Austritt" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/VertragTab.test.tsx b/tests/components/VertragTab.test.tsx index 3532fc3..1163381 100644 --- a/tests/components/VertragTab.test.tsx +++ b/tests/components/VertragTab.test.tsx @@ -50,6 +50,8 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow { is_betriebsrat: false, has_dienstwagen: false, dienstwagen_art: null, + has_kuendigungsschutz: false, + kuendigungsschutz_bis: null, is_laterale_fuehrung: false, is_c_level: false, title_prefix: [], @@ -102,3 +104,22 @@ describe("VertragTab — Merkmale", () => { expect(wertZu("Laterale Führung")).toBe("Nein"); }); }); + +describe("VertragTab — Kündigungsschutz", () => { + it("nennt das Enddatum statt eines blossen Ja", () => { + // „Ja" allein liesse offen, ob der Schutz noch läuft — und genau danach + // fragt jemand, der auf dieses Blatt schaut. + render(); + expect(wertZu("Besonderer Kündigungsschutz")).toBe("bis 30.06.2027"); + }); + + it("sagt es auch, wenn das Ende offen ist", () => { + render(); + expect(wertZu("Besonderer Kündigungsschutz")).toBe("Ja (Ende offen)"); + }); + + it("steht ohne Schutz auf Nein", () => { + render(); + expect(wertZu("Besonderer Kündigungsschutz")).toBe("Nein"); + }); +});