diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index 7d33bee..5644edb 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -13,13 +13,14 @@ import { Field, SelectField, TextField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries"; +import { HAY_GRADES } from "@/lib/hay-grade"; import { STUNDEN_GRUENDE } from "@/lib/absence"; import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz"; import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart"; import { fmtFullName, todayIso } from "@/lib/format"; import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr"; import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection"; -import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/types"; +import type { ContractType, Database, EmploymentType, GenderType, PaygradeType } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"]; @@ -79,6 +80,7 @@ export function DatenAendernPanel({ const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? ""); const [contractType, setContractType] = useState(employee.contract_type); const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? ""); + const [paygrade, setPaygrade] = useState(employee.paygrade); const [role, setRole] = useState({ workerType: employee.worker_type ?? "Angestellte:r", @@ -176,6 +178,7 @@ export function DatenAendernPanel({ weekly_hours: Number(weeklyHours), contract_type: contractType, contract_end_date: contractType === "befristet" ? contractEndDate : "", + paygrade, }, role: { worker_type: role.workerType, @@ -372,6 +375,18 @@ export function DatenAendernPanel({ {contractType === "befristet" && ( )} + {/* Auch über die Beförderung zu setzen — und trotzdem hier. Eine + Umstufung ist nicht immer eine Beförderung: „da stand von + Anfang an die falsche Stufe" ist eine Richtigstellung, und die + soll keine Planstelle wechseln und kein Ereignis „Beförderung" + in der Historie hinterlassen. */} + setPaygrade(v as PaygradeType)} + options={HAY_GRADES} + /> diff --git a/db/migrations/20260929100000_hay_grade_in_daten_aendern.sql b/db/migrations/20260929100000_hay_grade_in_daten_aendern.sql new file mode 100644 index 0000000..8831730 --- /dev/null +++ b/db/migrations/20260929100000_hay_grade_in_daten_aendern.sql @@ -0,0 +1,199 @@ +-- Der Hay-Grade in „Daten ändern" +-- +-- Die Beförderung konnte ihn schon setzen, „Daten ändern" nicht: weder das +-- Formular noch change_employee_data kannten das Feld. Damit war eine +-- Einstufung nur über den Weg „Beförderung" zu ändern — und eine Korrektur +-- („da stand von Anfang an die falsche Stufe") ist keine Beförderung, sondern +-- eine Richtigstellung, die auch keine Planstelle wechseln soll. +-- +-- Das Feld gehört zur Gruppe `contract` und ist damit **datiert**: eine +-- Umstufung gilt ab einem Tag, nicht rückwirkend seit immer. Das heisst, sie +-- kann in pending_org_changes landen — und deshalb steht sie unten auch im +-- Nachtlauf. Genau diese zweite Stelle ist in diesem Projekt schon einmal +-- vergessen worden: die Gruppe `role` fehlte dort monatelang, und eine +-- datierte Änderung an Betriebsrat oder Kollektivvertrag wurde als `applied` +-- vermerkt, ohne etwas zu tun. +-- +-- Drittens app_feld_karte: ohne Eintrag dort liesse sich der Eintrag in der +-- Historie hinterher nicht richtigstellen. +-- +-- Alle drei Funktionen werden **nicht** aus einer Datei kopiert, sondern aus +-- der laufenden Definition gelesen und an genau einem Anker ergänzt. Jeder +-- Anker muss genau einmal vorkommen; sonst bricht die Migration ab, statt an +-- einer Stelle zu schreiben, die hier niemand angesehen hat. + +-- ═══ 1. change_employee_data ══════════════════════════════════════ +do $migration$ +declare + -- Der Eintrag im Änderungsprotokoll … + v_alt_log constant text := $anker$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;$anker$; + v_neu_log constant text := $anker$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_contract ? 'paygrade' then v_contract_changes := app_aenderung(v_contract_changes, 'Hay-Grade', v_old.paygrade::text, v_contract->>'paygrade'); end if;$anker$; + + -- … und die Spalte selbst. coalesce und nicht nullif: die Stufe ist NOT NULL, + -- sie lässt sich nicht leeren, nur wechseln. + v_alt_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),$anker$; + v_neu_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type), + paygrade = coalesce((v_contract->>'paygrade')::paygrade_type, paygrade),$anker$; + + v_def text; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f'; + + if v_def is null then + raise exception 'change_employee_data ist nicht vorhanden.'; + end if; + + if (length(v_def) - length(replace(v_def, v_alt_log, ''))) / length(v_alt_log) <> 1 then + raise exception 'Der Anker für das Protokoll kommt nicht genau einmal vor.'; + end if; + if (length(v_def) - length(replace(v_def, v_alt_upd, ''))) / length(v_alt_upd) <> 1 then + raise exception 'Der Anker für die Spalte kommt nicht genau einmal vor.'; + end if; + if v_def like '%paygrade%' then + raise exception 'change_employee_data kennt den Hay-Grade bereits.'; + end if; + + execute replace(replace(v_def, v_alt_log, v_neu_log), v_alt_upd, v_neu_upd); +end +$migration$; + +-- ═══ 2. apply_due_pending_changes ═════════════════════════════════ +do $migration$ +declare + v_alt constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),$anker$; + v_neu constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type), + paygrade = coalesce((v_rec.payload->'contract'->>'paygrade')::paygrade_type, paygrade),$anker$; + v_def text; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f'; + + if v_def is null then + raise exception 'apply_due_pending_changes ist nicht vorhanden.'; + end if; + + if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then + raise exception 'Der Anker im Nachtlauf kommt nicht genau einmal vor.'; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ 3. app_feld_karte ════════════════════════════════════════════ +do $migration$ +declare + -- Der zweite Eintrag ist der Typ, den update_history_entry unmittelbar als + -- Umwandlung ins SQL setzt. paygrade_type ist seit 20260928140000 eine + -- Domain über text; die Prüfung der Stufe passiert damit dort, wo sie + -- hingehört, und nicht an einer zweiten Liste in dieser Funktion. + v_alt constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'),$anker$; + v_neu constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'), + 'Hay-Grade', jsonb_build_array('paygrade', 'paygrade_type', 'contract'),$anker$; + v_def text; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'app_feld_karte' and p.prokind = 'f'; + + if v_def is null then + raise exception 'app_feld_karte ist nicht vorhanden.'; + end if; + + if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then + raise exception 'Der Anker in der Feldkarte kommt nicht genau einmal vor.'; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ 4. Selbstprüfung ═════════════════════════════════════════════ +-- +-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren +-- Neuerzeugungen in diesem Projekt schon einmal still verschwunden ist. +do $$ +declare + v_def text; + v_anz int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f'; + + if v_def not like '%''Hay-Grade''%' then + raise exception 'change_employee_data protokolliert den Hay-Grade nicht.'; + end if; + if v_def not like '%v_contract->>''paygrade''%' then + raise exception 'change_employee_data schreibt den Hay-Grade nicht.'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'change_employee_data hat die Rechtepruefung verloren.'; + end if; + if v_def not like '%search_path%' then + raise exception 'change_employee_data hat den search_path verloren.'; + end if; + if v_def not like '%cornerstone_id%' then + raise exception 'change_employee_data hat die Cornerstone-ID verloren.'; + end if; + -- Die Form, die eine leere Angabe als null schreibt statt als '' (siehe + -- 20260923160000). Ginge sie verloren, scheiterte die zweite Person ohne + -- E-Mail am eindeutigen Index. + if v_def not like '%nullif(v_person->>''email'', '''')%' then + raise exception 'change_employee_data hat die Leerstring-Behandlung verloren.'; + end if; + + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f'; + + if v_def not like '%''contract''->>''paygrade''%' then + raise exception 'Der Nachtlauf wendet den Hay-Grade nicht an.'; + end if; + if v_def not like '%v_rec.payload->''role''%' then + raise exception 'Der Nachtlauf hat die Gruppe role verloren.'; + end if; + -- Zwei Zweige hängen eine Besetzung um: die Versetzung und die Beförderung + -- auf eine andere Planstelle. Gezählt und nicht bloss auf Vorhandensein + -- geprüft — einer der beiden ist hier schon einmal verschwunden. + v_anz := (length(v_def) - length(replace(v_def, 'insert into position_assignments', ''))) + / length('insert into position_assignments'); + if v_anz <> 2 then + raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anz; + end if; + -- Und was zuletzt hier verlorenging, bleibt drin. + if v_def not like '%emergency_contact_name%' then + raise exception 'Der Nachtlauf hat den Notfallkontakt wieder verloren.'; + end if; + if v_def not like '%target_position_id%' then + raise exception 'Der Nachtlauf hat die Zielplanstelle wieder verloren.'; + end if; + -- Der Lauf hat keine angemeldete Person: er arbeitet als SECURITY DEFINER + -- und ruft deshalb **nicht** require_hr_admin(). Ginge das Kennzeichen bei + -- einer Neuerzeugung verloren, liefe er nachts in die Zeilenschutz-Regeln + -- und täte nichts mehr. + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prosecdef + ) then + raise exception 'Der Nachtlauf ist nicht mehr SECURITY DEFINER.'; + end if; + + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'app_feld_karte' and p.prokind = 'f'; + + if v_def not like '%''Hay-Grade''%' then + raise exception 'Die Feldkarte kennt den Hay-Grade nicht.'; + end if; + if v_def not like '%cornerstone_id%' then + raise exception 'Die Feldkarte hat die Cornerstone-ID verloren.'; + end if; + + raise notice 'Hay-Grade steht in Daten aendern, im Nachtlauf und in der Feldkarte.'; +end +$$; diff --git a/lib/historie-felder.ts b/lib/historie-felder.ts index b15c699..914f927 100644 --- a/lib/historie-felder.ts +++ b/lib/historie-felder.ts @@ -1,4 +1,5 @@ import { ABSENCE_TYPES } from "./absence"; +import { HAY_GRADE_WERTE } from "./hay-grade"; import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz"; import { MITARBEITERARTEN } from "./mitarbeiterart"; import { EMERGENCY_RELATIONS } from "./types"; @@ -27,6 +28,12 @@ import { EMERGENCY_RELATIONS } from "./types"; // Fehlwert. Das gehört gemeinsam mit der Anzeigeschicht gelöst, nicht // nebenbei hier. export const FELD_AUSWAHLEN: Record = { + // Kein Aufzählungstyp mehr, sondern eine Domain über text — die Liste darf + // hier also stehen. Angezeigt wird der **gespeicherte** Wert und nicht der + // Titel: „-" statt „Generic Grade". In einem Dialog, der eine Zeile der + // Historie richtigstellt, ist genau das die richtige Anzeige, denn dort + // steht hinterher derselbe Wert. + "Hay-Grade": HAY_GRADE_WERTE, "Notfallkontakt Verhältnis": EMERGENCY_RELATIONS, "Kündigungsschutz Personenkreis": KUENDIGUNGSSCHUTZ_GRUENDE, Mitarbeiterart: MITARBEITERARTEN, diff --git a/tests/unit/historie-felder.test.ts b/tests/unit/historie-felder.test.ts index dcfe603..4ed3b8f 100644 --- a/tests/unit/historie-felder.test.ts +++ b/tests/unit/historie-felder.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder"; @@ -9,15 +9,24 @@ import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder"; // landeten wieder in der Spalte. Ein stiller Rückfall auf den Zustand, den // diese Datei beheben soll. // -// Geprüft wird gegen die zuletzt gültige Fassung der Feldkarte. -const MIGRATION = join(process.cwd(), "db/migrations/20260923120000_firmenemail.sql"); +// Gelesen werden **alle** Migrationen, nicht eine bestimmte. Hier stand +// einmal ein fester Dateiname mit dem Vermerk „die zuletzt gültige Fassung"; +// der war schon zwei Migrationen später falsch, und ein Teil der Feldkarte +// kommt inzwischen ohnehin aus einer Punktänderung an der laufenden +// Definition statt aus einer vollständigen Fassung. Eine Bezeichnung, die +// irgendwann einmal eingetragen wurde, zählt damit als bekannt — das ist +// schwächer als der Stand von heute, aber es ist die Frage, die hier zählt: +// ob jemand einen Schlüssel erfunden hat, den es nie gab. +const MIGRATIONEN = join(process.cwd(), "db/migrations"); function bezeichnungenDerFeldkarte(): string[] { - const sql = readFileSync(MIGRATION, "utf8"); - const block = sql.match(/FUNCTION public\.app_feld_karte\(\)[\s\S]*?\$function\$;/)?.[0]; - expect(block, "app_feld_karte steht nicht mehr in dieser Migration").toBeDefined(); // Je Zeile: 'Bezeichnung', jsonb_build_array('spalte', 'typ', 'gruppe'), - return [...block!.matchAll(/^\s*'([^']+)',\s+jsonb_build_array\(/gm)].map((m) => m[1]); + const muster = /'([^']+)',\s+jsonb_build_array\('[a-z_]+', '[a-z_]+'/g; + const gefunden = readdirSync(MIGRATIONEN) + .filter((f) => f.endsWith(".sql")) + .flatMap((f) => [...readFileSync(join(MIGRATIONEN, f), "utf8").matchAll(muster)].map((m) => m[1])); + expect(gefunden.length, "Die Feldkarte steht in keiner Migration mehr").toBeGreaterThan(0); + return gefunden; } describe("Die Auswahllisten im Dialog „Eintrag berichtigen“", () => {