setHatTitel(v === "ja")}
+ options={[
+ { value: "nein", label: "Nein" },
+ { value: "ja", label: "Ja" },
+ ]}
+ hint="Bei Staatsbürgerschaften ausserhalb von EU, EWR und Schweiz."
+ />
+ {hatTitel && (
+
+ )}
+ >
+ )}
diff --git a/components/employees/tabs/StammdatenTab.tsx b/components/employees/tabs/StammdatenTab.tsx
index 00fcf16..a70c39c 100644
--- a/components/employees/tabs/StammdatenTab.tsx
+++ b/components/employees/tabs/StammdatenTab.tsx
@@ -1,4 +1,5 @@
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
+import { brauchtAufenthaltstitel } from "@/lib/countries";
import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
@@ -31,6 +32,21 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
["SV-Nummer", employee.sv_nummer ?? "–"],
["Staatsbürgerschaft", employee.nationality],
+ // Nur, wo er verlangt ist. Bei einer österreichischen Staatsbürger-
+ // schaft wäre die Zeile „Aufenthaltstitel: Nein" keine Auskunft,
+ // sondern eine Frage, die sich nicht stellt.
+ ...(brauchtAufenthaltstitel(employee.nationality)
+ ? ([
+ [
+ "Aufenthaltstitel",
+ employee.hat_aufenthaltstitel
+ ? employee.aufenthaltstitel_bis
+ ? `Ja, bis ${fmtDate(employee.aufenthaltstitel_bis)}`
+ : "Ja (unbefristet oder nicht erfasst)"
+ : "Nein",
+ ],
+ ] as [string, string][])
+ : []),
["Adresse", employee.address ?? "–"],
["Postleitzahl", employee.postal_code ?? "–"],
["Ort", employee.city ?? "–"],
diff --git a/lib/countries.ts b/lib/countries.ts
index 1bec166..f67982f 100644
--- a/lib/countries.ts
+++ b/lib/countries.ts
@@ -197,3 +197,63 @@ export const UN_COUNTRIES: string[] = [
"Zentralafrikanische Republik",
"Zypern",
].sort((a, b) => a.localeCompare(b, "de"));
+
+// Wer keinen Aufenthaltstitel braucht.
+//
+// EU-27, dazu Island, Liechtenstein und Norwegen (EWR) sowie die Schweiz.
+// Für diese Staatsangehörigen gilt Freizügigkeit; ein Aufenthaltstitel ist
+// weder nötig noch zu erfassen. Für alle übrigen ist er es, und dann will HR
+// wissen, bis wann er läuft.
+//
+// Die Liste steht hier und nicht in der Datenbank: eine Prüfbedingung, die
+// Staatsbürgerschaft und Titel koppelt, würde bei jeder Korrektur der
+// Staatsbürgerschaft zuschlagen — und Beitritte müssten als Migration
+// nachgezogen werden, statt als Zeile in einer Liste.
+export const FREIZUEGIGKEIT: readonly string[] = [
+ // EU
+ "Belgien",
+ "Bulgarien",
+ "Dänemark",
+ "Deutschland",
+ "Estland",
+ "Finnland",
+ "Frankreich",
+ "Griechenland",
+ "Irland",
+ "Italien",
+ "Kroatien",
+ "Lettland",
+ "Litauen",
+ "Luxemburg",
+ "Malta",
+ "Niederlande",
+ "Österreich",
+ "Polen",
+ "Portugal",
+ "Rumänien",
+ "Schweden",
+ "Slowakei",
+ "Slowenien",
+ "Spanien",
+ "Tschechien",
+ "Ungarn",
+ "Zypern",
+ // EWR
+ "Island",
+ "Liechtenstein",
+ "Norwegen",
+ // Bilaterale Freizügigkeit
+ "Schweiz",
+];
+
+/**
+ * Braucht jemand mit dieser Staatsbürgerschaft einen Aufenthaltstitel?
+ *
+ * Bei leerer Angabe: nein. Eine fehlende Staatsbürgerschaft ist kein Anlass,
+ * jemandem einen Titel abzuverlangen — sie ist ein Anlass, sie zu erfassen.
+ */
+export function brauchtAufenthaltstitel(nationality: string | null | undefined): boolean {
+ const n = nationality?.trim();
+ if (!n) return false;
+ return !FREIZUEGIGKEIT.includes(n);
+}
diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts
index 0fe130c..7c89368 100644
--- a/lib/report-criteria.ts
+++ b/lib/report-criteria.ts
@@ -77,6 +77,7 @@ export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
{ key: "clevel", label: "C-Level" },
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
{ key: "teilzeit", label: "In einer Teilzeitvariante" },
+ { key: "aufenthaltstitel", label: "Aufenthaltstitel" },
{ key: "dependents", label: "Angehörige erfasst" },
] as const;
@@ -89,6 +90,7 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
{ key: "birth", label: "Geburtsdatum" },
{ key: "schutz", label: "Kündigungsschutz bis" },
{ key: "teilzeitEnde", label: "Teilzeit endet" },
+ { key: "titelEnde", label: "Aufenthaltstitel läuft ab" },
] as const;
export type Criteria = {
diff --git a/lib/reports-data.ts b/lib/reports-data.ts
index fda67fb..055d1cf 100644
--- a/lib/reports-data.ts
+++ b/lib/reports-data.ts
@@ -76,6 +76,7 @@ export function applyCriteria(
// „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche.
if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null);
if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null);
+ if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "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 +90,10 @@ export function applyCriteria(
if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis);
if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von);
if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis);
+ // Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im
+ // nächsten Quartal aus".
+ if (c.zeitraum.titelEnde?.von) q = q.where("aufenthaltstitel_bis", ">=", c.zeitraum.titelEnde.von);
+ if (c.zeitraum.titelEnde?.bis) q = q.where("aufenthaltstitel_bis", "<=", c.zeitraum.titelEnde.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 f751ea4..ca5bb24 100644
--- a/lib/supabase/types.ts
+++ b/lib/supabase/types.ts
@@ -149,6 +149,10 @@ export type Database = {
birth_date: string;
sv_nummer: string | null;
nationality: string;
+ /** Aufenthaltstitel — nur ausserhalb von EU, EWR und Schweiz erhoben. */
+ hat_aufenthaltstitel: boolean;
+ /** Gültig bis, falls befristet. Nur mit dem Kennzeichen zusammen erlaubt. */
+ aufenthaltstitel_bis: string | null;
address: string | null;
postal_code: string | null;
city: string | null;
@@ -208,6 +212,8 @@ export type Database = {
birth_date: string;
sv_nummer?: string | null;
nationality?: string;
+ hat_aufenthaltstitel?: boolean;
+ aufenthaltstitel_bis?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;
diff --git a/supabase/migrations/20260814200000_aufenthaltstitel.sql b/supabase/migrations/20260814200000_aufenthaltstitel.sql
new file mode 100644
index 0000000..05a00aa
--- /dev/null
+++ b/supabase/migrations/20260814200000_aufenthaltstitel.sql
@@ -0,0 +1,414 @@
+-- Aufenthaltstitel — für alle ohne Freizügigkeit.
+--
+-- EU-, EWR- und Schweizer Staatsangehörige brauchen keinen; für alle übrigen
+-- ist er Voraussetzung der Beschäftigung, und dann will die Personalabteilung
+-- wissen, bis wann er läuft. Rund achtzig Personen im Bestand betrifft das.
+--
+-- Zwei Spalten, dasselbe Muster wie beim Kündigungsschutz: ein Kennzeichen
+-- und ein Datum, das nur mit ihm zusammen Sinn ergibt. Das Datum ist
+-- freiwillig — ein unbefristeter Titel hat keines, und ein Pflichtfeld zwänge
+-- dort zu einer erfundenen Zahl.
+--
+-- ═══ Was die Datenbank *nicht* prüft ═══
+--
+-- Die Kopplung an die Staatsbürgerschaft steht bewusst nicht als Bedingung
+-- hier drin. Sie hätte zwei Nachteile, die schwerer wiegen als der Gewinn:
+--
+-- * Die Länderliste müsste doppelt geführt werden — einmal in SQL, einmal
+-- in lib/countries.ts, wo die Oberfläche sie ohnehin braucht. Zwei Listen
+-- laufen auseinander, und ein EU-Beitritt wäre dann eine Migration statt
+-- einer Zeile.
+-- * Eine Korrektur der Staatsbürgerschaft würde an der Bedingung scheitern,
+-- solange der Titel noch dransteht — also genau in dem Moment, in dem
+-- jemand einen Fehler geradebiegt.
+--
+-- Stattdessen entscheidet die Oberfläche anhand der Liste, ob die Felder
+-- überhaupt erscheinen, und räumt sie weg, sobald die Staatsbürgerschaft in
+-- den Freizügigkeitsraum wechselt.
+
+alter table employees
+ add column if not exists hat_aufenthaltstitel boolean not null default false,
+ add column if not exists aufenthaltstitel_bis date;
+
+comment on column employees.hat_aufenthaltstitel is
+ 'Aufenthaltstitel vorhanden. Nur bei Staatsangehörigkeiten ausserhalb von EU, EWR und Schweiz erhoben — siehe lib/countries.ts.';
+comment on column employees.aufenthaltstitel_bis is
+ 'Gültig bis, falls befristet. Freiwillig; leer heisst unbefristet oder nicht erfasst.';
+
+alter table employees drop constraint if exists chk_aufenthaltstitel_bis;
+alter table employees add constraint chk_aufenthaltstitel_bis
+ check (hat_aufenthaltstitel or aufenthaltstitel_bis is null);
+
+comment on constraint chk_aufenthaltstitel_bis on employees is
+ 'Ein Ablaufdatum ohne Titel wäre ein Rest ohne Bezug. hat_aufenthaltstitel ist NOT NULL, deshalb genügt die einfache Oder-Form.';
+
+create index if not exists idx_employees_aufenthaltstitel_bis
+ on employees (aufenthaltstitel_bis) where aufenthaltstitel_bis 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 bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
+ 'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
+ 'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
+ -- 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. Wer sie in einer
+ -- geplanten Änderung setzen wollte, hätte keinen Ort dafür — deshalb
+ -- weisen delete_/update_history_entry solche Einträge in der Zukunft
+ -- ab, statt an einer fehlenden Gruppe zu scheitern.
+ '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;
+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,
+ 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),
+ 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,
+ 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[];
+ -- Die Teilzeitvariante ist jetzt ein Feld an der Person (teilzeit_art)
+ -- und läuft über die gewöhnliche Änderungsliste. Der frühere Anhang am
+ -- Beschreibungstext ist damit weg — zweimal dasselbe zu schreiben lädt
+ -- nur dazu ein, dass die zwei Fassungen auseinanderlaufen.
+ v_pending_id uuid;
+begin
+ perform require_hr_admin();
+ select * into v_old from employees where id = v_employee_id;
+ v_name := v_old.first_name || ' ' || v_old.last_name;
+ v_immediate := v_effective_date <= current_date;
+
+ -- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
+ -- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
+ -- auf null gemeldet.
+ if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
+ if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
+ if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
+ -- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
+ -- „2026-08-03" nicht als Änderung gelten.
+ if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
+ if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
+ if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
+ if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
+ if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
+ if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
+ if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
+ if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
+ if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
+ if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
+ if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
+ if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
+ if v_person ? '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_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
+ if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
+ if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
+ if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
+
+ if v_immediate then
+ update employees set
+ first_name = coalesce(v_person->>'first_name', first_name),
+ last_name = coalesce(v_person->>'last_name', last_name),
+ gender = coalesce((v_person->>'gender')::gender_type, gender),
+ birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
+ sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
+ nationality = coalesce(v_person->>'nationality', nationality),
+ address = coalesce(v_person->>'address', address),
+ postal_code = coalesce(v_person->>'postal_code', postal_code),
+ city = coalesce(v_person->>'city', city),
+ address_country = coalesce(v_person->>'address_country', address_country),
+ email = coalesce(v_person->>'email', email),
+ phone = coalesce(v_person->>'phone', phone),
+ emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
+ emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
+ emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
+ 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),
+ -- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
+ -- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
+ -- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
+ teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
+ -- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
+ -- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
+ teilzeit_bis = case
+ when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
+ when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
+ else teilzeit_bis
+ end,
+ kuendigungsschutz_bis = case
+ when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
+ case when v_role ? 'kuendigungsschutz_bis'
+ then nullif(v_role->>'kuendigungsschutz_bis','')::date
+ else kuendigungsschutz_bis end
+ else null
+ end,
+ dienstwagen_art = case
+ when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
+ coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
+ else null
+ end
+ where id = v_employee_id;
+ elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
+ insert into pending_org_changes (employee_id, change_type, effective_date, payload)
+ values (v_employee_id, 'contract_change', v_effective_date, payload)
+ returning id into v_pending_id;
+ end if;
+
+ if jsonb_array_length(v_person_changes) > 0 then
+ insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
+ values (v_employee_id, v_effective_date, 'Stammdatenänderung',
+ 'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
+ insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
+ values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
+ app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
+ end if;
+
+ if jsonb_array_length(v_contract_changes) > 0 then
+ insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
+ values (v_employee_id, v_effective_date, 'Vertragsänderung',
+ 'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
+ insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
+ values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
+ app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
+ end if;
+end;
+$function$;
+
+
+-- 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 '%hat_aufenthaltstitel%' then
+ raise exception 'hire_employee nimmt den Aufenthaltstitel nicht entgegen';
+ end if;
+ if v_chg not like '%Aufenthaltstitel bis%' then
+ raise exception 'change_employee_data protokolliert den Aufenthaltstitel nicht';
+ end if;
+ if app_feld_karte()->'Aufenthaltstitel'->>2 <> 'person' 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 756d2ec..6592d1c 100644
--- a/tests/components/TerminatePanel.test.tsx
+++ b/tests/components/TerminatePanel.test.tsx
@@ -28,6 +28,8 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow {
birth_date: "1990-04-01",
sv_nummer: null,
nationality: "Österreich",
+ hat_aufenthaltstitel: false,
+ aufenthaltstitel_bis: null,
address: null,
address_country: null,
postal_code: null,
diff --git a/tests/components/VertragTab.test.tsx b/tests/components/VertragTab.test.tsx
index b8d0a21..259653e 100644
--- a/tests/components/VertragTab.test.tsx
+++ b/tests/components/VertragTab.test.tsx
@@ -21,6 +21,8 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow {
birth_date: "1990-04-01",
sv_nummer: null,
nationality: "Österreich",
+ hat_aufenthaltstitel: false,
+ aufenthaltstitel_bis: null,
address: null,
address_country: null,
postal_code: null,
diff --git a/tests/unit/countries.test.ts b/tests/unit/countries.test.ts
new file mode 100644
index 0000000..2af59b9
--- /dev/null
+++ b/tests/unit/countries.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import { brauchtAufenthaltstitel, FREIZUEGIGKEIT, UN_COUNTRIES } from "@/lib/countries";
+
+// Wer keinen Aufenthaltstitel braucht, soll auch nicht danach gefragt werden.
+// Die Liste entscheidet darüber, ob die Felder im Formular überhaupt
+// erscheinen und ob sie beim Wechsel der Staatsbürgerschaft geleert werden —
+// die Datenbank prüft diese Kopplung bewusst nicht.
+
+describe("FREIZUEGIGKEIT", () => {
+ it("umfasst EU, EWR und die Schweiz", () => {
+ expect(FREIZUEGIGKEIT).toHaveLength(31); // 27 + Island/Liechtenstein/Norwegen + Schweiz
+ for (const land of ["Österreich", "Deutschland", "Kroatien", "Zypern", "Island", "Liechtenstein", "Norwegen", "Schweiz"]) {
+ expect(FREIZUEGIGKEIT).toContain(land);
+ }
+ });
+
+ it("nennt nur Länder, die es im Auswahlfeld auch gibt", () => {
+ // Sonst stünde ein Land auf der Liste, das niemand auswählen kann — die
+ // Regel liefe für einen Wert, der nie vorkommt.
+ for (const land of FREIZUEGIGKEIT) expect(UN_COUNTRIES).toContain(land);
+ });
+
+ it("führt kein Land doppelt", () => {
+ expect(new Set(FREIZUEGIGKEIT).size).toBe(FREIZUEGIGKEIT.length);
+ });
+
+ it("hält Drittstaaten heraus", () => {
+ for (const land of ["Türkei", "Serbien", "Bosnien und Herzegowina", "Vereinigtes Königreich"]) {
+ expect(FREIZUEGIGKEIT).not.toContain(land);
+ }
+ });
+});
+
+describe("brauchtAufenthaltstitel", () => {
+ it("verneint bei Freizügigkeit", () => {
+ for (const land of ["Österreich", "Deutschland", "Schweiz", "Norwegen"]) {
+ expect(brauchtAufenthaltstitel(land)).toBe(false);
+ }
+ });
+
+ it("bejaht bei Drittstaaten", () => {
+ // Diese drei stellen im Bestand rund achtzig Personen.
+ for (const land of ["Türkei", "Serbien", "Bosnien"]) {
+ expect(brauchtAufenthaltstitel(land)).toBe(true);
+ }
+ });
+
+ it("verneint bei fehlender Angabe", () => {
+ // Eine fehlende Staatsbürgerschaft ist kein Anlass, einen Titel zu
+ // verlangen — sie ist ein Anlass, sie zu erfassen.
+ for (const leer of ["", " ", null, undefined]) {
+ expect(brauchtAufenthaltstitel(leer)).toBe(false);
+ }
+ });
+
+ it("stört sich nicht an Leerzeichen am Rand", () => {
+ expect(brauchtAufenthaltstitel(" Österreich ")).toBe(false);
+ });
+});