diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 4305d06..016b860 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -207,7 +207,7 @@ export default async function DashboardPage({ // „(HC)" für Headcount — der Unterschied zur Kachel links ist genau // der, den die Abkürzung benennt: Köpfe, die heute arbeiten. { label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" }, - { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, + { label: "Langzeitabwesende", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, // Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur // den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier // Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht. @@ -224,13 +224,14 @@ export default async function DashboardPage({ titel: "Personalbewegung", kacheln: [ { - label: "Eintritte (Jahr)", + // YTD: seit Jahresbeginn bis heute, nicht das ganze Kalenderjahr. + label: "Eintritte (YTD)", value: hiresYtd, tone: "success", href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, }, { - label: "Austritte (Jahr)", + label: "Austritte (YTD)", value: exitsYtd, tone: "danger", href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, @@ -363,7 +364,20 @@ export default async function DashboardPage({
- {h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"} + {/* Der Name führt in die Akte. Ohne den Link war die Karte + eine Sackgasse: man sah, dass etwas passiert ist, und + musste die Person danach in der Liste suchen. */} + {h.first_name && h.last_name ? ( + + {fmtName(h.first_name, h.last_name)} + + ) : ( + Unbekannt + )} {h.event_type} diff --git a/components/audit/AuditFilters.tsx b/components/audit/AuditFilters.tsx index ac311a3..551fe6c 100644 --- a/components/audit/AuditFilters.tsx +++ b/components/audit/AuditFilters.tsx @@ -22,7 +22,11 @@ const ACTIONS: { value: string; label: string }[] = [ { value: "Karenz", label: "Langzeitabwesenheit" }, { value: "Vertragsänderung", label: "Vertragsänderung" }, { value: "Stammdatenänderung", label: "Stammdatenänderung" }, - { value: "Gehaltsanpassung", label: "Gehaltsanpassung" }, + // „Gehaltsanpassung" steht nicht mehr zur Auswahl: das Gehalt ist aus dem + // Funktionsumfang genommen (es wird in Loga geführt), keine Funktion + // schreibt diese Art mehr, und ein Filter, der immer leer ausgeht, sieht + // aus wie ein Fehler. Der Wert bleibt im Aufzählungstyp — alte Einträge + // aus der Zeit davor sollen lesbar bleiben. ]; export function AuditFilters() { diff --git a/components/employees/HistorieBearbeiten.tsx b/components/employees/HistorieBearbeiten.tsx index 9d59f99..8b8dd7c 100644 --- a/components/employees/HistorieBearbeiten.tsx +++ b/components/employees/HistorieBearbeiten.tsx @@ -9,6 +9,7 @@ import { TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; +import { auswahlFuer } from "@/lib/historie-felder"; import type { AuditChange } from "@/lib/types"; // Berichtigen, nicht neu erfassen. @@ -181,14 +182,37 @@ export function HistorieBearbeiten({ {c.vorher === null || c.vorher === "" ? leer : c.vorher} - setWerte((v) => ({ ...v, [c.feld]: e.target.value }))} - className="w-full rounded border border-border px-2 py-1 text-sm text-ink - focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500" - /> + {/* Führt das Feld eine feste Liste, wird sie auch hier + angeboten: getippt entstünde sonst ein Wert, den keine + Auswertung mehr findet — die Berichtigung schreibt in + dieselbe Spalte wie das Formular, nur ohne dessen + Prüfung. Welche Felder das sind, steht in + lib/historie-felder.ts. */} + {auswahlFuer(c.feld) ? ( + + ) : ( + setWerte((v) => ({ ...v, [c.feld]: e.target.value }))} + className="w-full rounded border border-border px-2 py-1 text-sm text-ink + focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500" + /> + )} ))} diff --git a/components/employees/NotfallkontaktModal.tsx b/components/employees/NotfallkontaktModal.tsx index f536a19..b336901 100644 --- a/components/employees/NotfallkontaktModal.tsx +++ b/components/employees/NotfallkontaktModal.tsx @@ -42,7 +42,6 @@ export function NotfallkontaktModal({ const [name, setName] = useState(kontakt.name ?? ""); const [telefon, setTelefon] = useState(kontakt.telefon ?? ""); const [verhaeltnis, setVerhaeltnis] = useState(kontakt.verhaeltnis ?? ""); - const [effectiveDate, setEffectiveDate] = useState(todayIso()); const [pending, setPending] = useState(false); const vorhanden = Boolean(kontakt.name); @@ -58,7 +57,7 @@ export function NotfallkontaktModal({ setPending(true); const result = await changeEmployeeData({ employee_id: employeeId, - effective_date: effectiveDate, + effective_date: todayIso(), person: { emergency_contact_name: name.trim(), emergency_contact_phone: telefon.trim(), @@ -93,8 +92,11 @@ export function NotfallkontaktModal({ } > + {/* Kein „Wirksam ab": ein Notfallkontakt gilt ab sofort. Das Feld stand + hier, weil die Datenbankfunktion einen Stichtag kennt — bei einer + Telefonnummer, die im Ernstfall gewählt wird, ist ein Datum in der + Zukunft keine sinnvolle Angabe, sondern eine Frage zu viel. */}
- {/* Dieselbe Liste wie im Einstellungsassistenten (EMERGENCY_RELATIONS) diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index e52b43a..5c7edd0 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -2,7 +2,6 @@ import { SelectField, TextField } from "@/components/ui/Field"; import { - BEGUENSTIGUNG_AB_GRAD, GRUND_BEGUENSTIGT_BEHINDERT, KUENDIGUNGSSCHUTZ_GRUENDE, } from "@/lib/kuendigungsschutz"; @@ -222,7 +221,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen {value.hasKuendigungsschutz && ( <> onChange({ kuendigungsschutzGrund: v })} @@ -278,7 +277,6 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen type="number" value={value.behinderungGrad} onChange={(v) => onChange({ behinderungGrad: v })} - hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`} />
- {/* Eigener Block: im Ernstfall greift jemand danach, und dann darf - er nicht zwischen den Adressfeldern der Person untergehen. */} -
- Notfallkontakt -
- - - ({ value: r, label: r }))} - /> -
-
@@ -402,6 +385,26 @@ export function DatenAendernPanel({
+ + {/* Unterhalb der Angehörigen und ganz am Schluss — dieselbe Reihenfolge + wie im Reiter „Stammdaten". Vorher stand der Block mitten im + Abschnitt „Person", zwischen den Adressfeldern; beides sind + Personen im Umfeld, und die beiden gehören nebeneinander. */} +
+ Notfallkontakt +
+ + + ({ value: r, label: r }))} + /> +
+
); diff --git a/components/employees/tabs/HistorieTab.tsx b/components/employees/tabs/HistorieTab.tsx index dfbd1c7..1c053e1 100644 --- a/components/employees/tabs/HistorieTab.tsx +++ b/components/employees/tabs/HistorieTab.tsx @@ -36,7 +36,7 @@ type Sicht = "alle" | "anstehend" | "erledigt"; const SICHTEN: { value: Sicht; label: string }[] = [ { value: "alle", label: "Alle" }, { value: "anstehend", label: "Anstehend" }, - { value: "erledigt", label: "Gelaufen" }, + { value: "erledigt", label: "Vergangen" }, ]; export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) { diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index da91b17..5d5388e 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -643,11 +643,6 @@ export function ReportsPageClient(props: ReportsPageClientProps) { {mode === "snapshot" && (

Honestly-Report

-

- Teilnehmerliste für die Mitarbeiterbefragung: Personalnummer, Name, Standort und die - Organisationskette von der eigenen Einheit bis ganz oben. Sprache immer „de“, Rolle immer „Respondee“. - Die Spalte „Email“ bleibt leer, bis die Firmen-E-Mail-Adresse erfasst wird. -

Status im Export: {statusExportLabel}

diff --git a/db/migrations/20260923180000_austritt_anmerkung.sql b/db/migrations/20260923180000_austritt_anmerkung.sql new file mode 100644 index 0000000..5b8ee1f --- /dev/null +++ b/db/migrations/20260923180000_austritt_anmerkung.sql @@ -0,0 +1,129 @@ +-- Die Anmerkung beim Austritt wird wieder aufgeschrieben. +-- +-- Gefragt vom Kunden: „beim Austritt gibt es eine Anmerkung — wo sieht man +-- die nachher?" Die Antwort war: nirgends. Das Formular sammelt sie ein, die +-- Server Action reicht sie durch, und terminate_employee liess sie fallen. +-- +-- Bis 20260814100000 stand sie in der Beschreibung des Ereignisses: +-- +-- 'Austritt (' || exit_reason || ')' +-- || case when payload->>'note' <> '' then ' — ' || note else '' end +-- +-- Beim Umschreiben der Funktion für den Nichtantritt ist der zweite Teil +-- verlorengegangen, und bei der Austrittsart (20260917100000) wurde die +-- bereits verkürzte Fassung weitergereicht. Genau davor warnt die +-- Selbstprüfung jener Migration im Kommentar — „der Fehler, den ein +-- `create or replace` aus einer alten Vorlage leise macht" —, und für die +-- Anmerkung ist er trotzdem passiert. Deshalb steht unten eine Prüfung, die +-- ihn beim nächsten Mal abfängt. +-- +-- Die Anmerkung geht in **beide** Einträge: in die Personalakte, wo sie +-- hingehört, und ins Protokoll, wo ohne sie nicht nachvollziehbar wäre, +-- was jemand beim Austritt festgehalten hat. Angehängt an die fertige +-- Beschreibung und nicht in einen der Zweige: der Nichtantritt darf sie +-- genauso tragen. +-- +-- Bestehende Einträge bleiben, wie sie sind. Der Text ist nicht mehr da; er +-- wurde nie geschrieben, und es gibt nichts, woraus er sich rekonstruieren +-- liesse. + +CREATE OR REPLACE FUNCTION public.terminate_employee(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_exit date := (payload->>'exit_date')::date; + v_name text; + -- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie + -- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten. + v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show'; + v_entry date; + v_art text := nullif(payload->>'austrittsart', ''); + v_note text := nullif(btrim(payload->>'note'), ''); +begin + perform require_hr_admin(); + select first_name || ' ' || last_name, entry_date into v_name, v_entry + from employees where id = v_employee_id; + + -- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus. + -- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war: + -- die Statusableitung verlangt exit_date > Stichtag, um jemanden als + -- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist + -- damit keine zusätzliche Regel, sondern folgt aus dem Datum. + if v_no_show then + v_exit := v_entry; + end if; + + update employees set + -- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der + -- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen, + -- denn es gibt keinen Lauf, der sie später nachzieht. + status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end, + exit_date = v_exit, + exit_reason = payload->>'exit_reason', + austrittsart = v_art + where id = v_employee_id; + + -- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt + -- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf + -- die nächste besetzte Ebene. + if v_no_show then + -- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu + -- schliessen ginge nicht — chk_assignment_range verlangt ein echtes + -- Intervall. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder + -- frei, und es steht nirgends, jemand hätte sie je innegehabt. + delete from position_assignments + where employee_id = v_employee_id and valid_to is null; + else + update position_assignments set valid_to = v_exit + where employee_id = v_employee_id and valid_to is null; + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_exit, 'Austritt', + case when v_no_show + then 'Kein Antritt am ' || v_entry || ' (No Show)' + else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')' + || case when v_art is not null then ', ' || v_art else '' end end + || case when v_note is not null then ' — ' || v_note else '' end); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id, + case when v_no_show + then 'Kein Antritt am ' || v_entry || ' (No Show)' + else 'Austritt am ' || v_exit || case when v_art is not null then ', ' || v_art else '' end end + || case when v_note is not null then ' — ' || v_note else '' end); +end; +$function$; + +grant execute on function public.terminate_employee(payload jsonb) to alpenwerk_app; + + +-- Selbstprüfung. +do $$ +declare + v_term text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure); +begin + if v_term not like '%v_note%' then + raise exception 'terminate_employee schreibt die Anmerkung nicht — das Feld im Formular ginge wieder ins Leere.'; + end if; + + -- Was diese Funktion schon einmal verloren hat, darf sie nicht wieder + -- verlieren. Dieselben Prüfungen wie in 20260917100000, hier um die + -- Anmerkung ergänzt. + if v_term not like '%austrittsart%' then + raise exception 'terminate_employee schreibt die Austrittsart nicht mehr'; + end if; + if v_term not like '%v_no_show%' or v_term not like '%delete from position_assignments%' then + raise exception 'terminate_employee kennt den Nichtantritt nicht mehr'; + end if; + if position('SET search_path' in v_term) = 0 then + raise exception 'terminate_employee hat keinen festen search_path mehr'; + end if; + if v_term like '%auth.uid()%' then + raise exception 'terminate_employee ruft wieder auth.uid()'; + end if; +end +$$; diff --git a/lib/historie-felder.ts b/lib/historie-felder.ts new file mode 100644 index 0000000..b15c699 --- /dev/null +++ b/lib/historie-felder.ts @@ -0,0 +1,39 @@ +import { ABSENCE_TYPES } from "./absence"; +import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz"; +import { MITARBEITERARTEN } from "./mitarbeiterart"; +import { EMERGENCY_RELATIONS } from "./types"; + +// Welche Felder in „Eintrag berichtigen" eine Auswahl statt eines Textfeldes +// bekommen. +// +// Der Dialog zeigte für jedes Feld dasselbe freie Textfeld — auch für +// „Notfallkontakt Verhältnis", wo die Erfassung überall sonst eine Liste +// führt. Wer dort „Gattin" statt „Gattin/Gatte" eintippte, erzeugte einen +// Wert, den keine Auswertung mehr findet, und merkte nichts davon: die +// Berichtigung schreibt in dieselbe Spalte wie das Formular, nur ohne dessen +// Prüfung. +// +// Die Schlüssel sind die Bezeichnungen aus `app_feld_karte()` — genau die +// Zeichenketten, die in `employee_history.changes` unter `feld` stehen. Ein +// Tippfehler hier fiele nicht auf, das Feld bliebe einfach ein Textfeld; +// deshalb prüft tests/unit/historie-felder.test.ts jeden Schlüssel gegen die +// Migration. +// +// Bewusst **nicht** aufgenommen sind die Felder mit Aufzählungstypen in der +// Datenbank (Geschlecht, Beschäftigungsausmaß, Vertragsart, Besetzungsart …) +// und die Ja/Nein-Felder. Dort ist der gespeicherte Text nicht die Anzeige — +// `m`/`w`, `true`/`false` —, und eine Liste, die die Anzeigeform schreibt, +// liefe in einen Umwandlungsfehler oder, schlimmer, in einen stillen +// Fehlwert. Das gehört gemeinsam mit der Anzeigeschicht gelöst, nicht +// nebenbei hier. +export const FELD_AUSWAHLEN: Record = { + "Notfallkontakt Verhältnis": EMERGENCY_RELATIONS, + "Kündigungsschutz Personenkreis": KUENDIGUNGSSCHUTZ_GRUENDE, + Mitarbeiterart: MITARBEITERARTEN, + "Art der Abwesenheit": ABSENCE_TYPES, +}; + +/** Die Auswahl zu einem Feld, oder nichts — dann bleibt es ein Textfeld. */ +export function auswahlFuer(feld: string): readonly string[] | undefined { + return FELD_AUSWAHLEN[feld]; +} diff --git a/tests/components/HistorieTab.test.tsx b/tests/components/HistorieTab.test.tsx index 04dd447..cb280b5 100644 --- a/tests/components/HistorieTab.test.tsx +++ b/tests/components/HistorieTab.test.tsx @@ -263,10 +263,10 @@ describe("HistorieTab — Filter", () => { expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument(); }); - it("blendet mit „Gelaufen“ das Anstehende aus", async () => { + it("blendet mit „Vergangen“ das Anstehende aus", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: "Gelaufen" })); + await user.click(screen.getByRole("button", { name: "Vergangen" })); expect(screen.queryByText("Versetzung künftig")).not.toBeInTheDocument(); expect(screen.getByText("Versetzung damals")).toBeInTheDocument(); }); diff --git a/tests/unit/historie-felder.test.ts b/tests/unit/historie-felder.test.ts new file mode 100644 index 0000000..dcfe603 --- /dev/null +++ b/tests/unit/historie-felder.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder"; + +// Die Schlüssel sind Bezeichnungen aus `app_feld_karte()`. Stimmt eine nicht +// mehr — umbenannt, vertippt —, fällt das nirgends auf: das Feld bliebe im +// Dialog „Eintrag berichtigen" einfach ein Textfeld, und getippte Werte +// 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"); + +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]); +} + +describe("Die Auswahllisten im Dialog „Eintrag berichtigen“", () => { + it("nennen nur Felder, die es in der Feldkarte gibt", () => { + const bekannt = new Set(bezeichnungenDerFeldkarte()); + expect(bekannt.size).toBeGreaterThan(30); + for (const feld of Object.keys(FELD_AUSWAHLEN)) { + expect(bekannt, `„${feld}“ steht nicht in app_feld_karte()`).toContain(feld); + } + }); + + it("bieten für das Verhältnis des Notfallkontakts eine Liste an", () => { + // Der gemeldete Fall: dort liess sich freier Text eintragen, während die + // Erfassung überall sonst eine Liste führt. + expect(auswahlFuer("Notfallkontakt Verhältnis")).toContain("Gattin/Gatte"); + expect(auswahlFuer("Notfallkontakt Verhältnis")).toContain("Tochter"); + }); + + it("lassen Felder ohne feste Liste als Textfeld stehen", () => { + expect(auswahlFuer("Adresse")).toBeUndefined(); + expect(auswahlFuer("Wochenstunden")).toBeUndefined(); + }); + + it("führen keine Felder mit Aufzählungstyp", () => { + // Dort ist der gespeicherte Wert nicht die Anzeige (`m`/`w`, + // `true`/`false`). Eine Liste, die die Anzeigeform schreibt, liefe in + // einen Umwandlungsfehler oder in einen stillen Fehlwert. + for (const feld of ["Geschlecht", "Beschäftigungsausmaß", "Vertragsart", "Besetzungsart", "Betriebsrat"]) { + expect(auswahlFuer(feld), `„${feld}“ gehört nicht in die Auswahl`).toBeUndefined(); + } + }); +});