diff --git a/actions/employees.ts b/actions/employees.ts index 7f558e2..a6fe8ef 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -6,7 +6,7 @@ import { withUser } from "@/lib/db"; import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; import { OFFBOARDING_PUNKTE } from "@/lib/offboarding"; import { ONBOARDING_PUNKTE } from "@/lib/onboarding"; -import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types"; +import type { CollectiveAgreement, DienstwagenArt, NoteCategory, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types"; async function callRpc(fn: MutationFn, payload: Record, revalidate: string[]): Promise { const result = await runMutation(await currentUserId(), fn, payload); @@ -97,7 +97,7 @@ export async function hireEmployee(payload: { contract_end_date?: string; employment_type?: "Vollzeit" | "Teilzeit"; weekly_hours?: number; - paygrade?: "A" | "B" | "C" | "D" | "E" | "F"; + paygrade?: PaygradeType; source: "Intern" | "Extern"; worker_type?: WorkerType; collective_agreement?: CollectiveAgreement; @@ -216,7 +216,7 @@ export async function promoteEmployee(payload: { employee_id: string; effective_date: string; new_title: string; - new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F"; + new_paygrade?: PaygradeType; /** * Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt. * diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index fbaf494..7d2405d 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -97,7 +97,7 @@ function employeeExportColumns( { header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" }, { header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung }, { header: "C-Level", get: (e) => e.is_c_level }, - { header: "Paygrade", get: (e) => e.paygrade }, + { header: "Hay-Grade", get: (e) => e.paygrade }, { header: "Herkunft", get: (e) => e.source }, // The export shows the display name, not the raw enum value — a payroll // hand-off saying "Karenz" for what the app calls Langzeitabwesenheit diff --git a/components/employees/panels/PromotePanel.tsx b/components/employees/panels/PromotePanel.tsx index bf5584f..afb3dfa 100644 --- a/components/employees/panels/PromotePanel.tsx +++ b/components/employees/panels/PromotePanel.tsx @@ -7,21 +7,13 @@ import { Button } from "@/components/ui/Button"; import { SelectField, TextField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; +import { HAY_GRADES } from "@/lib/hay-grade"; import type { OpenPositionResolved } from "@/lib/positions"; import type { Database, PaygradeType } from "@/lib/types"; import { fmtName } from "@/lib/format"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; -const PAYGRADES: { value: PaygradeType; label: string }[] = [ - { value: "A", label: "A – Einstieg" }, - { value: "B", label: "B – Qualifiziert" }, - { value: "C", label: "C – Erfahren" }, - { value: "D", label: "D – Spezialist:in" }, - { value: "E", label: "E – Teamleitung" }, - { value: "F", label: "F – Bereichsleitung / GF" }, -]; - // Eine Beförderung kann auf derselben Planstelle stattfinden oder auf eine // andere führen. Beides kommt vor: jemand wächst auf seiner Stelle, oder er // rückt auf eine höhere — und dann ist die Zielstelle dieselbe Auswahl wie @@ -173,10 +165,10 @@ export function PromotePanel({ setPaygrade(v as PaygradeType)} - options={PAYGRADES} + options={HAY_GRADES} /> diff --git a/components/employees/tabs/VertragTab.tsx b/components/employees/tabs/VertragTab.tsx index c730bc7..5bbf3cf 100644 --- a/components/employees/tabs/VertragTab.tsx +++ b/components/employees/tabs/VertragTab.tsx @@ -1,18 +1,10 @@ import { dienstwagenLabel } from "@/lib/dienstwagen"; import { fmtDate } from "@/lib/format"; +import { hayGradeLabel } from "@/lib/hay-grade"; import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; -const PAYGRADE_LABELS: Record = { - A: "A – Einstieg", - B: "B – Qualifiziert", - C: "C – Erfahren", - D: "D – Spezialist:in", - E: "E – Teamleitung", - F: "F – Bereichsleitung / GF", -}; - export function VertragTab({ employee }: { employee: EmployeeRow }) { // Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf, // durch Kommas getrennt — und ein Gedankenstrich, wenn nichts zutraf. Damit @@ -26,7 +18,7 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) { ["Beschäftigungsausmaß", employee.employment_type], ["Wochenstunden", `${employee.weekly_hours} h`], ["Urlaubsanspruch", "25 Tage"], - ["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade], + ["Hay-Grade", hayGradeLabel(employee.paygrade)], ["Beschäftigtengruppe", employee.worker_type ?? "–"], ["Arbeitstage", employee.work_days?.join(", ") || "–"], // Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das diff --git a/components/hire/StepSummary.tsx b/components/hire/StepSummary.tsx index ac99587..64ba4f1 100644 --- a/components/hire/StepSummary.tsx +++ b/components/hire/StepSummary.tsx @@ -1,17 +1,9 @@ import { dienstwagenLabel } from "@/lib/dienstwagen"; import { fmtDate, fmtFullName } from "@/lib/format"; +import { hayGradeLabel } from "@/lib/hay-grade"; import type { OpenPositionResolved } from "@/lib/positions"; import type { HireDraftData } from "./types"; -const PAYGRADE_LABELS: Record = { - A: "A – Einstieg", - B: "B – Qualifiziert", - C: "C – Erfahren", - D: "D – Spezialist:in", - E: "E – Teamleitung", - F: "F – Bereichsleitung / GF", -}; - type StepSummaryProps = { draft: HireDraftData; selectedPosition: OpenPositionResolved | null; @@ -49,7 +41,7 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP ["Eintrittsdatum", fmtDate(draft.entryDate)], ["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"], ["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`], - ["Paygrade", PAYGRADE_LABELS[draft.paygrade]], + ["Hay-Grade", hayGradeLabel(draft.paygrade)], ["Beschäftigtengruppe", draft.workerType], ["Kollektivvertrag", draft.collectiveAgreement], ["Arbeitstage", draft.workDays.join(", ") || "–"], diff --git a/components/hire/StepVertrag.tsx b/components/hire/StepVertrag.tsx index 2eb7c2f..a8461bb 100644 --- a/components/hire/StepVertrag.tsx +++ b/components/hire/StepVertrag.tsx @@ -1,17 +1,9 @@ import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields"; import { SelectField, TextField } from "@/components/ui/Field"; +import { HAY_GRADES } from "@/lib/hay-grade"; import type { PaygradeType } from "@/lib/types"; import type { HireDraftData } from "./types"; -const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [ - { value: "A", label: "A – Einstieg", description: "Berufseinsteiger:innen ohne einschlägige Erfahrung" }, - { value: "B", label: "B – Qualifiziert", description: "Fachkräfte mit abgeschlossener Ausbildung" }, - { value: "C", label: "C – Erfahren", description: "Mehrjährige einschlägige Berufserfahrung" }, - { value: "D", label: "D – Spezialist:in", description: "Vertiefte Fachexpertise" }, - { value: "E", label: "E – Teamleitung", description: "Fachliche und disziplinäre Führung eines Teams" }, - { value: "F", label: "F – Bereichsleitung / GF", description: "Führung eines Bereichs bzw. Geschäftsführung" }, -]; - export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial) => void }) { function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) { update({ @@ -63,12 +55,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: ( /> update({ paygrade: v as PaygradeType })} - options={PAYGRADES} - hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description} + options={HAY_GRADES} />

Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.

diff --git a/components/hire/types.ts b/components/hire/types.ts index 4d35df6..13c6f40 100644 --- a/components/hire/types.ts +++ b/components/hire/types.ts @@ -1,3 +1,4 @@ +import { HAY_GRADE_STANDARD } from "@/lib/hay-grade"; import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart"; import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, Mitarbeiterart, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types"; @@ -96,7 +97,7 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = { contractEndDate: "", employmentType: "Vollzeit", weeklyHours: "38.5", - paygrade: "B", + paygrade: HAY_GRADE_STANDARD, workerType: "Angestellte:r", collectiveAgreement: "Handel", workDays: ["Mo", "Di", "Mi", "Do", "Fr"], diff --git a/db/migrations/20260928140000_hay_grade.sql b/db/migrations/20260928140000_hay_grade.sql new file mode 100644 index 0000000..e868ee3 --- /dev/null +++ b/db/migrations/20260928140000_hay_grade.sql @@ -0,0 +1,247 @@ +-- Hay-Grade statt Verwendungsgruppe A–F +-- +-- ═══ 1. Warum die Liste wechselt ═══════════════════════════════════ +-- +-- A–F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene +-- Stufen mit erfundenen Beschreibungen („B – Qualifiziert"). Der Kunde +-- bewertet nach Hay und hat die Liste geschickt — 13 Stufen plus einen +-- „Generic Grade" für alles, was keine trägt. +-- +-- Die Spalte füllt im Cornerstone-Extrakt die „Grade ID". Solange dort A–F +-- steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen +-- auffällt: das Zielsystem kennt diese Kennungen nicht. +-- +-- ═══ 2. Warum eine Domain und kein Aufzählungstyp ══════════════════ +-- +-- `paygrade_type` war ein Enum. Ein Enum lässt sich nicht umschreiben: +-- Werte entfernen geht gar nicht, und `alter type … add value` darf im +-- selben Vorgang, der den neuen Wert schreibt, nicht benutzt werden — die +-- Umstellung bräuchte also zwei Migrationen und liesse dazwischen eine +-- Datenbank mit beiden Listen zurück. +-- +-- Der Typ **heisst** deshalb weiter `paygrade_type`, ist aber jetzt eine +-- Domain über text mit CHECK. Das ist die Form, die dieses Projekt für +-- kundenseitige Listen ohnehin schon führt (Abwesenheitsart, Personenkreis, +-- Mitarbeiterart) — und, wichtiger: die rund zehn SQL-Funktionen, die +-- `(payload->>'paygrade')::paygrade_type` schreiben, bleiben unverändert +-- gültig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu +-- haben, aus einer veralteten Vorlage zu kopieren; genau daran hat dieses +-- Projekt schon dreimal Verhalten verloren. +-- +-- Eine einzige Stelle muss doch angefasst werden: `hire_employee` setzt als +-- Vorgabe das Literal 'B', und das besteht den CHECK nicht mehr. Sie wird +-- unten **nicht** aus einer Datei kopiert, sondern aus der laufenden +-- Definition gelesen und an genau dieser Stelle geändert. + +-- ═══ 3. Hängt der Typ noch woanders? ══════════════════════════════ +-- +-- Die Dateien sagen: nur employees.paygrade. Die Datenbank ist die Quelle, +-- nicht die Dateien — also gefragt statt angenommen. Hinge eine zweite +-- Spalte daran, scheiterte der DROP weiter unten ohnehin, aber mit einer +-- Meldung, die nicht sagt, was los ist. +do $$ +declare + v_spalten text; +begin + select string_agg(c.relname || '.' || a.attname, ', ') + into v_spalten + from pg_attribute a + join pg_class c on c.oid = a.attrelid + join pg_namespace n on n.oid = c.relnamespace + where a.atttypid = 'paygrade_type'::regtype + and a.attnum > 0 + and not a.attisdropped + and n.nspname = 'public' + and not (c.relname = 'employees' and a.attname = 'paygrade'); + + if v_spalten is not null then + raise exception 'paygrade_type wird noch benutzt von: %. Umstellung abgebrochen.', v_spalten; + end if; +end +$$; + +-- ═══ 4. Offene datierte Beförderungen ═════════════════════════════ +-- +-- Eine Beförderung mit Datum in der Zukunft liegt als pending-Zeile mit +-- `new_paygrade` im Nutzdatensatz. Steht dort A–F, scheiterte der Nachtlauf +-- Wochen später an der Umwandlung — um drei Uhr früh und ohne jemanden, dem +-- die Meldung zugestellt würde. Lieber jetzt und laut. +do $$ +declare + v_anzahl int; +begin + select count(*) into v_anzahl + from pending_org_changes + where status = 'pending' + and payload ? 'new_paygrade' + and payload->>'new_paygrade' not in + ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20'); + + if v_anzahl > 0 then + raise exception + 'Es warten % geplante Beförderungen auf eine Verwendungsgruppe, die es nicht mehr gibt. Erst entscheiden, dann umstellen.', + v_anzahl; + end if; +end +$$; + +-- ═══ 5. Der Typwechsel ════════════════════════════════════════════ +alter table employees alter column paygrade drop default; +alter table employees alter column paygrade type text using paygrade::text; + +drop type paygrade_type; + +create domain paygrade_type as text + constraint chk_hay_grade check ( + value in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20') + ); + +-- Der Bestand bekommt „Generic Grade". Aus A–F liesse sich kein Hay-Grade +-- ableiten: andere Einteilung, andere Anzahl. Geraten sähe im Extrakt +-- genauso aus wie erhoben — und wäre nicht mehr davon zu unterscheiden. +update employees + set paygrade = '-' + where paygrade not in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20'); + +alter table employees alter column paygrade type paygrade_type using paygrade::paygrade_type; +alter table employees alter column paygrade set default '-'; + +-- ═══ 6. hire_employee: Vorgabewert 'B' → '-' ══════════════════════ +-- +-- Aus der laufenden Definition gelesen, nicht aus einer Migrationsdatei +-- kopiert: ein Teil der Funktionen dieses Projekts ist nachträglich +-- dynamisch gepatcht worden, die Summe der Dateien ist also nicht das, was +-- in der Datenbank steht. Der Anker muss genau einmal vorkommen — käme er +-- keinmal vor, hätte jemand die Vorgabe schon geändert und wir überschrieben +-- etwas Unbekanntes; käme er zweimal vor, träfe die Ersetzung eine Stelle, +-- die hier niemand angeschaut hat. +do $migration$ +declare + v_alt constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, 'B')$anker$; + v_neu constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, '-')$anker$; + v_def text; + v_anzahl 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 = 'hire_employee' + and p.prokind = 'f'; + + if v_def is null then + raise exception 'hire_employee ist nicht vorhanden.'; + end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Vorgabewert kommt % mal vor, erwartet genau einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ 6b. promote_employee: die Beschriftung im Protokoll ══════════ +-- +-- Die Funktion schreibt den Grund einer Beförderung als Klartext nach +-- audit_log und employee_history: „…, neue Verwendungsgruppe: C". Diese +-- Zeilen liest der Kunde. Bliebe das Wort stehen, hiesse ein Feld in der +-- Maske anders als im Protokoll darüber — dieselbe Änderung unter zwei +-- Namen. Bestehende Einträge bleiben, wie sie sind: Historie wird +-- fortgeschrieben, nicht umgeschrieben. +do $migration$ +declare + v_alt constant text := $anker$, neue Verwendungsgruppe: $anker$; + v_neu constant text := $anker$, neuer Hay-Grade: $anker$; + v_def text; + v_anzahl 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 = 'promote_employee' + and p.prokind = 'f'; + + if v_def is null then + raise exception 'promote_employee ist nicht vorhanden.'; + end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Die Beschriftung kommt % mal vor, erwartet genau einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ 7. Selbstprüfung ═════════════════════════════════════════════ +-- +-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren +-- Neuerzeugungen schon einmal still verschwunden ist: Rechteprüfung, +-- search_path und die zuletzt hinzugekommenen Felder. `hire_employee` ist +-- gerade neu erzeugt worden — wenn dabei etwas abhanden gekommen wäre, wäre +-- es hier zu sehen und nirgends sonst. +do $$ +declare + v_def text; + v_werte int; +begin + select count(*) into v_werte + from pg_constraint + where conname = 'chk_hay_grade' + and contypid = 'paygrade_type'::regtype; + if v_werte <> 1 then + raise exception 'Die Bedingung chk_hay_grade fehlt an paygrade_type.'; + end if; + + if exists (select 1 from employees where paygrade::text = 'B') then + raise exception 'Es stehen noch Verwendungsgruppen in employees.paygrade.'; + 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 = 'hire_employee' and p.prokind = 'f'; + + if v_def not like '%paygrade_type, ''-''%' then + raise exception 'hire_employee traegt den neuen Vorgabewert nicht.'; + end if; + if v_def like '%paygrade_type, ''B''%' then + raise exception 'hire_employee traegt noch den alten Vorgabewert.'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'hire_employee hat die Rechtepruefung verloren.'; + end if; + if v_def not like '%search_path%' then + raise exception 'hire_employee hat den search_path verloren.'; + end if; + if v_def not like '%cornerstone_id%' then + raise exception 'hire_employee hat die Cornerstone-ID verloren.'; + end if; + if v_def not like '%company_email%' then + raise exception 'hire_employee hat die Firmen-E-Mail 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 = 'promote_employee' and p.prokind = 'f'; + + if v_def not like '%neuer Hay-Grade%' then + raise exception 'promote_employee traegt die neue Beschriftung nicht.'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'promote_employee hat die Rechtepruefung verloren.'; + end if; + -- Die Zielplanstelle kam erst mit 20260924100000 dazu und ist genau die Art + -- Verhalten, die bei einer Neuerzeugung schon einmal still verschwunden ist. + if v_def not like '%target_position_id%' then + raise exception 'promote_employee hat die Zielplanstelle verloren.'; + end if; + + raise notice 'Hay-Grade steht, hire_employee und promote_employee vollstaendig.'; +end +$$; diff --git a/docs/datenkatalog.md b/docs/datenkatalog.md index 810b46a..0b245c9 100644 --- a/docs/datenkatalog.md +++ b/docs/datenkatalog.md @@ -108,7 +108,7 @@ die kommt über die Planstelle. | `contract_end_date` | date | ja | – | Pflicht bei `befristet` | | `worker_type` | `worker_type` | – | `Angestellte:r` | | | `collective_agreement` | `collective_agreement` | – | `Handel` | | -| `paygrade` | `paygrade_type` | – | `B` | A–F | +| `paygrade` | `paygrade_type` | – | `-` | Hay-Grade; der Spaltenname ist geblieben | | `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt | | `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert | | `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet | @@ -304,7 +304,7 @@ eigenen Zeilen sind ohnehin immer dabei. Die Tabelle hiess bis September 2026 | `contract_type` | unbefristet, befristet | | `worker_type` | Angestellte:r, Arbeiter:in, Lehrling | | `collective_agreement` | Handel, Süßwaren | -| `paygrade_type` | A, B, C, D, E, F | +| `paygrade_type` | -, HG09…HG19, HG19P, HG20 (Domain über text, kein Aufzählungstyp) | | `source_type` | Intern, Extern | | `gender_type` | m, w | | `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team | diff --git a/lib/hay-grade.ts b/lib/hay-grade.ts new file mode 100644 index 0000000..1138911 --- /dev/null +++ b/lib/hay-grade.ts @@ -0,0 +1,55 @@ +import type { PaygradeType } from "./types"; + +// Die Hay-Grades — die Bewertungsstufen, die der Kunde tatsächlich führt. +// +// Bis September 2026 stand an dieser Stelle eine Verwendungsgruppe A–F samt +// Beschreibungen („B – Qualifiziert"). Die stammte aus der Spezifikation, nicht +// vom Kunden: sechs erfundene Stufen. Dieselbe Spalte füllt im +// Cornerstone-Extrakt die „Grade ID", und dort ist A–F keine Kennung, die das +// Zielsystem kennt — die Datei wäre in jeder Zeile falsch gewesen, ohne dass +// es beim Erzeugen aufgefallen wäre. +// +// Gespeichert wird die ID, angezeigt der Titel. Bei den HG-Stufen ist beides +// dasselbe; nur „Generic Grade" trägt als ID einen Bindestrich. Der steht so +// in der Tabelle des Kunden und wird hier nicht in eine leere Angabe +// übersetzt: ein Bindestrich ist dort ein Wert, kein fehlender. +// +// Dieselbe Liste steht in Migration 20260928140000 als CHECK der Domain +// paygrade_type; tests/unit/hay-grade.test.ts hält beide gegeneinander. +export const HAY_GRADES: readonly { value: PaygradeType; label: string }[] = [ + { value: "-", label: "Generic Grade" }, + { value: "HG09", label: "HG09" }, + { value: "HG10", label: "HG10" }, + { value: "HG11", label: "HG11" }, + { value: "HG12", label: "HG12" }, + { value: "HG13", label: "HG13" }, + { value: "HG14", label: "HG14" }, + { value: "HG15", label: "HG15" }, + { value: "HG16", label: "HG16" }, + { value: "HG17", label: "HG17" }, + { value: "HG18", label: "HG18" }, + { value: "HG19", label: "HG19" }, + { value: "HG19P", label: "HG19P" }, + { value: "HG20", label: "HG20" }, +] as const; + +export const HAY_GRADE_WERTE: readonly PaygradeType[] = HAY_GRADES.map((g) => g.value); + +/** + * Die Vorgabe — und zugleich der Wert, den der Bestand bei der Umstellung + * bekommen hat. + * + * Aus A–F liesse sich kein Hay-Grade ableiten: die alten Stufen waren eine + * andere Einteilung mit einer anderen Anzahl. Jede Zuordnung wäre geraten, + * und geraten sähe im Extrakt genauso aus wie erhoben. + */ +export const HAY_GRADE_STANDARD: PaygradeType = "-"; + +/** Der anzuzeigende Titel; unbekannte Werte bleiben, wie sie in der Zeile stehen. */ +export function hayGradeLabel(wert: string | null | undefined): string { + return HAY_GRADES.find((g) => g.value === wert)?.label ?? wert ?? "–"; +} + +export function istHayGrade(wert: string | null | undefined): wert is PaygradeType { + return HAY_GRADE_WERTE.includes(wert as PaygradeType); +} diff --git a/lib/import/load.ts b/lib/import/load.ts index 5b1a48a..df81523 100644 --- a/lib/import/load.ts +++ b/lib/import/load.ts @@ -1,6 +1,7 @@ import "server-only"; import { sql, type Tx } from "@/lib/db"; import { todayIso } from "@/lib/format"; +import { HAY_GRADE_STANDARD } from "@/lib/hay-grade"; import { deriveStatusAsOf } from "@/lib/reports"; import { normalizeSvnr } from "@/lib/svnr"; import { sortiereWochentage } from "@/lib/wochentage"; @@ -229,7 +230,7 @@ export async function laden( work_days: sortiereWochentage(liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never, contract_type: (txt(w.contract_type) ?? "unbefristet") as never, contract_end_date: txt(w.contract_end_date), - paygrade: (txt(w.paygrade) ?? "B") as never, + paygrade: (txt(w.paygrade) ?? HAY_GRADE_STANDARD) as never, collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never, worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never, monthly_salary_gross: zahl(w.monthly_salary_gross), diff --git a/lib/import/schema.ts b/lib/import/schema.ts index b290d74..7d7f9b8 100644 --- a/lib/import/schema.ts +++ b/lib/import/schema.ts @@ -1,4 +1,5 @@ import { ABSENCE_TYPES } from "@/lib/absence"; +import { HAY_GRADE_WERTE } from "@/lib/hay-grade"; import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles"; // Was in einer Importdatei stehen darf. @@ -339,12 +340,12 @@ export const BLATT_PERSONEN: BlattSchema = { beispiel: "", }, { - name: "Verwendungsgruppe", + name: "Hay-Grade", ziel: "paygrade", pflicht: false, - typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] }, - hinweis: "Leer = B.", - beispiel: "C", + typ: { art: "auswahl", werte: [...HAY_GRADE_WERTE] }, + hinweis: "Leer = Generic Grade, also ein Bindestrich.", + beispiel: "HG13", }, { name: "Kollektivvertrag", diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts index b09b06e..ef083eb 100644 --- a/lib/report-criteria.ts +++ b/lib/report-criteria.ts @@ -1,5 +1,6 @@ import { ABSENCE_TYPES } from "./absence"; import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNGSART_WERTE } from "./beendigung"; +import { HAY_GRADE_WERTE } from "./hay-grade"; import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz"; import { MITARBEITERARTEN } from "./mitarbeiterart"; import { parseIsoDateParam } from "./reports"; @@ -49,7 +50,7 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [ { key: "worker", label: "Beschäftigtengruppe", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in", "Lehrling") }, { key: "mitarbeiterart", label: "Mitarbeiterart", alle: "Alle Mitarbeiterarten", optionen: werte(...MITARBEITERARTEN) }, { key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") }, - { key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") }, + { key: "paygrade", label: "Hay-Grade", alle: "Alle Hay-Grades", optionen: werte(...HAY_GRADE_WERTE) }, { key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") }, { key: "gender", diff --git a/lib/reports.ts b/lib/reports.ts index 3d1e47e..3ca4f24 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -51,7 +51,7 @@ export const GROUP_LABELS: Record = { contract_type: "Vertragsart", entry_year: "Eintrittsjahr", source: "Intern/Extern", - paygrade: "Paygrade", + paygrade: "Hay-Grade", worker_type: "Beschäftigtengruppe", collective_agreement: "Kollektivvertrag", betriebsrat: "Betriebsrat", @@ -349,7 +349,7 @@ export function totalForRows(rows: { value: number; count: number }[], measure: export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [ { name: "Headcount nach Bereich", measure: "headcount", group: "division" }, { name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" }, - { name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" }, + { name: "Headcount nach Hay-Grade", measure: "headcount", group: "paygrade" }, { name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" }, { name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" }, { name: "Headcount nach C-Level", measure: "headcount", group: "c_level" }, diff --git a/lib/types.ts b/lib/types.ts index 9c280c4..bb77345 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -6,7 +6,26 @@ export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten"; export type EmploymentType = "Vollzeit" | "Teilzeit"; export type ContractType = "unbefristet" | "befristet"; -export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F"; +/** + * Der Hay-Grade. Der Name der Spalte ist `paygrade` geblieben — sie steht in + * rund zehn SQL-Funktionen, und eine Umbenennung hätte jede davon angefasst, + * ohne dass sich am Inhalt etwas ändert. Die Liste führt lib/hay-grade.ts. + */ +export type PaygradeType = + | "-" + | "HG09" + | "HG10" + | "HG11" + | "HG12" + | "HG13" + | "HG14" + | "HG15" + | "HG16" + | "HG17" + | "HG18" + | "HG19" + | "HG19P" + | "HG20"; export type SourceType = "Intern" | "Extern"; export type GenderType = "m" | "w"; /** diff --git a/tests/components/TerminatePanel.test.tsx b/tests/components/TerminatePanel.test.tsx index a781b30..f60ce2b 100644 --- a/tests/components/TerminatePanel.test.tsx +++ b/tests/components/TerminatePanel.test.tsx @@ -43,7 +43,7 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow { monthly_salary_gross: null, contract_type: "unbefristet", contract_end_date: null, - paygrade: "C", + paygrade: "HG13", source: "Extern", status: "Geplant", entry_date: EINTRITT, diff --git a/tests/components/VertragTab.test.tsx b/tests/components/VertragTab.test.tsx index f96894d..ea23ea5 100644 --- a/tests/components/VertragTab.test.tsx +++ b/tests/components/VertragTab.test.tsx @@ -36,7 +36,7 @@ function mitarbeiter(teil: Partial = {}): EmployeeRow { monthly_salary_gross: null, contract_type: "unbefristet", contract_end_date: null, - paygrade: "C", + paygrade: "HG13", source: "Extern", status: "Aktiv", entry_date: "2017-07-15", diff --git a/tests/unit/hay-grade.test.ts b/tests/unit/hay-grade.test.ts new file mode 100644 index 0000000..a011d82 --- /dev/null +++ b/tests/unit/hay-grade.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { HAY_GRADES, HAY_GRADE_STANDARD, HAY_GRADE_WERTE, hayGradeLabel, istHayGrade } from "@/lib/hay-grade"; + +// Dieselbe Vorsichtsmassnahme wie bei Personenkreis und Mitarbeiterart: die +// Liste steht in der Migration als CHECK und hier für die Oberfläche. Laufen +// die beiden auseinander, bietet das Formular einen Wert an, den die Datenbank +// abweist — und die Benutzerin sieht einen Fehler aus dem Maschinenraum. + +const MIGRATION = join(process.cwd(), "db/migrations/20260928140000_hay_grade.sql"); + +function werteAusDerBedingung(): string[] { + const sql = readFileSync(MIGRATION, "utf8"); + const block = sql.match(/constraint chk_hay_grade check \([\s\S]*?\);/)?.[0]; + expect(block, "Die Bedingung steht nicht mehr in der Migration").toBeDefined(); + return [...block!.matchAll(/'([^']+)'/g)].map((m) => m[1]); +} + +describe("Die Hay-Grades", () => { + it("stimmen mit der Bedingung in der Datenbank überein", () => { + expect(new Set(werteAusDerBedingung())).toEqual(new Set(HAY_GRADE_WERTE)); + }); + + it("führen den Generic Grade als Vorgabe", () => { + // Die Spalte ist NOT NULL mit genau diesem Vorgabewert, und der Bestand + // hat ihn bei der Umstellung bekommen. Weicht die Oberfläche davon ab, + // bekämen neue Zeilen etwas anderes als die vorhandenen. + expect(HAY_GRADE_WERTE).toContain(HAY_GRADE_STANDARD); + expect(readFileSync(MIGRATION, "utf8")).toContain("set default '-'"); + }); + + it("zeigen den Titel und speichern die Kennung", () => { + // Nur beim Generic Grade gehen beide auseinander — genau deshalb darf die + // Anzeige nicht einfach der gespeicherte Wert sein. + expect(hayGradeLabel("-")).toBe("Generic Grade"); + expect(hayGradeLabel("HG15")).toBe("HG15"); + for (const g of HAY_GRADES) { + if (g.value !== "-") expect(g.label).toBe(g.value); + } + }); + + it("lassen einen unbekannten Wert stehen, statt ihn zu verschlucken", () => { + // Eine Zeile aus der Zeit vor der Umstellung soll sichtbar falsch sein, + // nicht unsichtbar leer. + expect(hayGradeLabel("B")).toBe("B"); + expect(hayGradeLabel(null)).toBe("–"); + }); + + it("weisen zurück, was nicht in der Liste steht", () => { + expect(istHayGrade("HG19P")).toBe(true); + expect(istHayGrade("HG21")).toBe(false); + expect(istHayGrade("B")).toBe(false); + expect(istHayGrade(null)).toBe(false); + }); +}); diff --git a/tests/unit/report-criteria.test.ts b/tests/unit/report-criteria.test.ts index 4193d2d..9668e1c 100644 --- a/tests/unit/report-criteria.test.ts +++ b/tests/unit/report-criteria.test.ts @@ -16,15 +16,15 @@ function aus(params: Record) { describe("parseCriteria", () => { it("nimmt bekannte Werte an", () => { - const c = aus({ employment: "Teilzeit", paygrade: "C", gender: "w" }); - expect(c.auswahl).toEqual({ employment: "Teilzeit", paygrade: "C", gender: "w" }); + const c = aus({ employment: "Teilzeit", paygrade: "HG13", gender: "w" }); + expect(c.auswahl).toEqual({ employment: "Teilzeit", paygrade: "HG13", gender: "w" }); }); it("verwirft Werte, die nicht in der Liste stehen", () => { // Die Werte landen in SQL-Vergleichen und im Dateinamen des Downloads, // also in einem Content-Disposition-Header — was nicht in der Liste // steht, darf nicht durchkommen. - const c = aus({ employment: "Vollzeit'; drop table employees; --", paygrade: "Z", gender: "x" }); + const c = aus({ employment: "Vollzeit'; drop table employees; --", paygrade: "HG21", gender: "x" }); expect(c.auswahl).toEqual({}); }); diff --git a/tests/unit/reports.test.ts b/tests/unit/reports.test.ts index 722524f..6421e1a 100644 --- a/tests/unit/reports.test.ts +++ b/tests/unit/reports.test.ts @@ -27,7 +27,7 @@ function emp(overrides: Partial = {}): ReportEmployee { exit_date: null, weekly_hours: 38.5, source: "Extern", - paygrade: "B", + paygrade: "HG13", birth_date: "1990-01-01", gender: "w", worker_type: "Angestellte:r",