diff --git a/actions/employees.ts b/actions/employees.ts index 3687f25..d1ff052 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -129,7 +129,19 @@ export async function changeEmployeeData(payload: { return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]); } -export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise { +/** + * `position_id` ist Pflicht — die Datenbankfunktion verlangt sie seit jeher. + * + * Die alte Stelle taugt nicht als stille Vorgabe: sie kann inzwischen besetzt + * oder ausgelaufen sein. Sie fehlte hier nur in der Signatur, weshalb jede + * Wiedereinstellung an einer Meldung scheiterte, die im Dialog nicht zu + * beheben war. + */ +export async function rehireEmployee(payload: { + employee_id: string; + rehire_date: string; + position_id: string; +}): Promise { return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); } diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index a846a3f..956d94b 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -175,7 +175,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) { locationCountry={location?.country} /> setPanel(null)} employee={employee} directReportCount={directReports.length} /> - setPanel(null)} employee={employee} /> + setPanel(null)} + employee={employee} + openPositions={openPositions} + /> ); } diff --git a/components/employees/panels/RehirePanel.tsx b/components/employees/panels/RehirePanel.tsx index 2b8b63c..887c425 100644 --- a/components/employees/panels/RehirePanel.tsx +++ b/components/employees/panels/RehirePanel.tsx @@ -1,30 +1,77 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { rehireEmployee } from "@/actions/employees"; import { Button } from "@/components/ui/Button"; -import { TextField } from "@/components/ui/Field"; +import { SelectField, TextField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; +import type { OpenPositionResolved } from "@/lib/positions"; import type { Database } from "@/lib/supabase/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; -export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) { +// Eine Wiedereinstellung braucht eine Planstelle — genau wie eine +// Neueinstellung. +// +// Die alte Stelle ist dafür kein Ersatz: sie kann inzwischen besetzt, +// ausgelaufen oder ganz entfallen sein. `rehire_employee` verlangte die +// Angabe deshalb schon immer und wies den Aufruf sonst ab; nur schickte das +// Formular sie nie mit, sodass jede Wiedereinstellung an einer Meldung +// scheiterte, die sich im Dialog gar nicht beheben liess. +export function RehirePanel({ + open, + onClose, + employee, + openPositions, +}: { + open: boolean; + onClose: () => void; + employee: EmployeeRow; + openPositions: OpenPositionResolved[]; +}) { const { showToast } = useToast(); const router = useRouter(); const [rehireDate, setRehireDate] = useState(""); + const [positionId, setPositionId] = useState(""); const [pending, setPending] = useState(false); + const options = useMemo( + () => + openPositions + .slice() + .sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de")) + .map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })), + [openPositions] + ); + + const selected = openPositions.find((p) => p.id === positionId); + + // Die Datenbank weist eine Besetzung ausserhalb der Gültigkeit ab. Das hier + // nimmt die Meldung vorweg, solange sie noch etwas nützt — im Dialog, mit + // beiden Daten sichtbar. + const ausserhalb = + selected && rehireDate + ? rehireDate < selected.valid_from + ? `Diese Planstelle gilt erst ab ${fmtDate(selected.valid_from)}.` + : selected.valid_to && rehireDate >= selected.valid_to + ? `Diese Planstelle gilt nur bis ${fmtDate(selected.valid_to)}.` + : null + : null; + async function handleSubmit() { - if (!rehireDate) { - showToast("Bitte ein Wiedereintrittsdatum angeben.", "error"); + if (!rehireDate || !positionId) { + showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error"); return; } setPending(true); - const result = await rehireEmployee({ employee_id: employee.id, rehire_date: rehireDate }); + const result = await rehireEmployee({ + employee_id: employee.id, + rehire_date: rehireDate, + position_id: positionId, + }); setPending(false); if (result.success) { showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`); @@ -46,7 +93,7 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos - @@ -58,7 +105,42 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos

{employee.job_title}

Ausgetreten am {fmtDate(employee.exit_date)}

+ + + {options.length === 0 ? ( +

+ Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine + Planstelle anlegen oder eine bestehende freimachen. +

+ ) : ( + <> + + {selected && ( +
+
{selected.title}
+
{selected.orgLabel}
+
+ {selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"} + {selected.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""} + {selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""} +
+
+ )} + {ausserhalb && ( +

+ {ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen. +

+ )} + + )} ); diff --git a/supabase/migrations/20260810120000_rehire_position_checks.sql b/supabase/migrations/20260810120000_rehire_position_checks.sql new file mode 100644 index 0000000..d203961 --- /dev/null +++ b/supabase/migrations/20260810120000_rehire_position_checks.sql @@ -0,0 +1,82 @@ +-- Die Wiedereinstellung prüft die Zielplanstelle wie Eintritt und Versetzung. +-- +-- rehire_employee verlangte zwar eine Planstelle, setzte die Besetzung dann +-- aber ungeprüft: weder ob die Stelle zum Wiedereintritt gilt noch ob sie +-- frei ist. Eine wiedereingestellte Person konnte damit auf einer bereits +-- besetzten Stelle landen — abgefangen erst vom Teilindex, mit einer Meldung, +-- die in der Oberfläche nichts erklärt — oder auf einer, die es zu dem Datum +-- gar nicht gibt. +-- +-- Dieselbe Regel wie in 20260810100000: das Datum muss in +-- [valid_from, valid_to) liegen, und es darf keine Zuordnung geben, die zu +-- diesem Zeitpunkt noch gilt. + +do $$ +declare + v_def text; + v_neu 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 = 'rehire_employee' + limit 1; + + if v_def is null then + raise exception 'rehire_employee() nicht gefunden.'; + end if; + + -- Über ein Muster, weil der Rumpf CRLF enthalten kann; ein wörtlicher + -- Vergleich fände nichts und die Migration meldete trotzdem Erfolg. + v_neu := regexp_replace( + v_def, + 'if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Für die Wiedereinstellung muss eine Planstelle angegeben werden\.'';\s+end\s+if;', +$neu$if v_position_id is null then + raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.'; + end if; + + declare + v_ab date; + v_bis date; + v_besetzt uuid; + begin + select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id; + if v_ab is null then + raise exception 'Die Planstelle existiert nicht.'; + end if; + if v_date < v_ab then + raise exception 'Die Planstelle gilt erst ab %. Ein Wiedereintritt am % ist darauf nicht möglich.', v_ab, v_date; + end if; + if v_bis is not null and v_date >= v_bis then + raise exception 'Die Planstelle gilt nur bis %. Ein Wiedereintritt am % ist darauf nicht möglich.', v_bis, v_date; + end if; + + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_position_id + and (pa.valid_to is null or pa.valid_to > v_date); + if v_besetzt is not null then + raise exception 'Diese Planstelle ist bereits besetzt.'; + end if; + end;$neu$, + 'g' + ); + + if v_neu = v_def then + raise exception 'Das Muster passte nicht — rehire_employee blieb unverändert.'; + end if; + + execute v_neu; +end; +$$; + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +do $$ +begin + if (select pg_get_functiondef(p.oid) from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'rehire_employee' limit 1) not like '%gilt erst ab%' then + raise exception 'rehire_employee() enthält die Gültigkeitsprüfung nicht.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260810130000_fix_rehire_status_cast.sql b/supabase/migrations/20260810130000_fix_rehire_status_cast.sql new file mode 100644 index 0000000..e36060b --- /dev/null +++ b/supabase/migrations/20260810130000_fix_rehire_status_cast.sql @@ -0,0 +1,65 @@ +-- rehire_employee funktionierte nie. +-- +-- status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end +-- +-- Der case-Ausdruck ist `text`, die Spalte ist `employment_status`. Postgres +-- weist das ab: +-- +-- column "status" is of type employment_status but expression is of type text +-- +-- Sichtbar wurde es erst jetzt. Davor brach die Funktion eine Zeile früher ab, +-- weil das Formular nie eine Planstelle mitschickte — ein Fehler verdeckte den +-- anderen, wie schon bei hire_employee. +-- +-- Bei den beiden anderen Zuweisungen im selben update (exit_date, exit_reason) +-- stellt sich die Frage nicht: `null` ist typunabhängig. + +do $$ +declare + v_def text; + v_neu 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 = 'rehire_employee' + limit 1; + + v_neu := regexp_replace( + v_def, + '(case\s+when\s+v_date\s*<=\s*current_date\s+then\s+''Aktiv''\s+else\s+''Geplant''\s+end)', + '(\1)::employment_status', + 'g' + ); + + if v_neu = v_def then + raise exception 'Das Muster für den Status passte nicht — rehire_employee blieb unverändert.'; + end if; + + execute v_neu; +end; +$$; + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +-- Nachgestellt wird die **Spaltenzuweisung**, nicht die an eine Variable. +-- Das ist der Unterschied, an dem mein erster Prüfausdruck vorbeiging: +-- plpgsql wandelt bei einer Variablenzuweisung stillschweigend um, ein +-- UPDATE auf eine Spalte nicht. Nur die zweite Form entspricht dem Fehler. +do $$ +begin + create temp table probe_status (s employment_status) on commit drop; + insert into probe_status values ('Aktiv'); + + begin + execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::text $probe$; + raise exception 'Eine text-Zuweisung an eine employment_status-Spalte wirft nicht mehr — die Prüfung ist wertlos geworden.'; + exception + when datatype_mismatch then null; -- erwartet + end; + + execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::employment_status $probe$; + if (select s from probe_status) <> 'Aktiv'::employment_status then + raise exception 'Die umgewandelte Form liefert nicht Aktiv.'; + end if; +end; +$$;