Hay-Grade statt Verwendungsgruppe A--F

A--F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene Stufen
mit erfundenen Beschreibungen. Der Kunde bewertet nach Hay und hat die Liste
geschickt -- 13 Stufen plus einen Generic Grade.

Dieselbe Spalte fuellt im Cornerstone-Extrakt die Grade ID. Solange dort A--F
steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
auffaellt: das Zielsystem kennt diese Kennungen nicht.

Der Typ heisst weiter paygrade_type, ist aber jetzt eine Domain ueber text mit
CHECK statt eines Aufzaehlungstyps. Ein Enum laesst sich nicht umschreiben --
Werte entfernen geht gar nicht, und add value darf im selben Vorgang, der den
neuen Wert schreibt, nicht benutzt werden. Wichtiger: die rund zehn
SQL-Funktionen, die den Wert nach paygrade_type umwandeln, bleiben unveraendert
gueltig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
haben, aus einer veralteten Vorlage zu kopieren.

Zwei Funktionen muessen doch angefasst werden, beide per Punktaenderung an der
laufenden Definition statt per Kopie aus einer Datei: der Vorgabewert B in
hire_employee und die Beschriftung im Protokoll von promote_employee.

Der Bestand bekommt den Generic Grade. Aus A--F liesse sich kein Hay-Grade
ableiten: andere Einteilung, andere Anzahl. Geraten saehe im Extrakt genauso
aus wie erhoben.
This commit is contained in:
2026-09-28 10:58:05 +02:00
parent c3e19606e2
commit 779beb4478
20 changed files with 413 additions and 65 deletions

View File

@@ -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<string, unknown>, revalidate: string[]): Promise<ActionResult> {
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.
*

View File

@@ -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

View File

@@ -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({
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
<SelectField
label="Verwendungsgruppe"
label="Hay-Grade"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={PAYGRADES}
options={HAY_GRADES}
/>
</div>
</SlideOver>

View File

@@ -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<string, string> = {
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

View File

@@ -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<string, string> = {
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(", ") || "–"],

View File

@@ -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<HireDraftData>) => void }) {
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
update({
@@ -63,12 +55,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
/>
</div>
<SelectField
label="Paygrade"
label="Hay-Grade"
required
value={draft.paygrade}
onChange={(v) => update({ paygrade: v as PaygradeType })}
options={PAYGRADES}
hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description}
options={HAY_GRADES}
/>
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>

View File

@@ -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"],

View File

@@ -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
$$;

View File

@@ -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 |

55
lib/hay-grade.ts Normal file
View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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",

View File

@@ -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",

View File

@@ -51,7 +51,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
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" },

View File

@@ -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";
/**

View File

@@ -43,7 +43,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
monthly_salary_gross: null,
contract_type: "unbefristet",
contract_end_date: null,
paygrade: "C",
paygrade: "HG13",
source: "Extern",
status: "Geplant",
entry_date: EINTRITT,

View File

@@ -36,7 +36,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): 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",

View File

@@ -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);
});
});

View File

@@ -16,15 +16,15 @@ function aus(params: Record<string, string>) {
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({});
});

View File

@@ -27,7 +27,7 @@ function emp(overrides: Partial<ReportEmployee> = {}): 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",