diff --git a/actions/employees.ts b/actions/employees.ts index 19b0b9c..72997bb 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -215,6 +215,13 @@ export async function promoteEmployee(payload: { effective_date: string; new_title: string; new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F"; + /** + * Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt. + * + * Fehlt sie, bleibt die Person auf ihrer bisherigen — das ist der andere + * der beiden Fälle, nicht ein vergessenes Feld. + */ + target_position_id?: string; }): Promise { return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]); } diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 9740c2c..94fecd6 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -223,7 +223,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) { employee={employee} openPositions={openPositions} /> - setPanel(null)} employee={employee} /> + setPanel(null)} + employee={employee} + openPositions={openPositions} + /> setPanel(null)} employee={employee} status={status} /> {/* 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 + wie im Reiter „Stammdaten". Aufgebaut wie „Person", „Vertrag" und + „Rolle & Anstellung" darüber: eine Überschrift, darunter die + Felder. Hier stand ein umrahmtes fieldset mit eigener + Beschriftung; das hob den Abschnitt gegenüber allen anderen + heraus, ohne dass er wichtiger wäre. */} +
+

Notfallkontakt

@@ -404,7 +406,7 @@ export function DatenAendernPanel({ options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))} />
-
+ ); diff --git a/components/employees/panels/PromotePanel.tsx b/components/employees/panels/PromotePanel.tsx index 5ca00ad..bf5584f 100644 --- a/components/employees/panels/PromotePanel.tsx +++ b/components/employees/panels/PromotePanel.tsx @@ -1,12 +1,13 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { promoteEmployee } from "@/actions/employees"; 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 type { OpenPositionResolved } from "@/lib/positions"; import type { Database, PaygradeType } from "@/lib/types"; import { fmtName } from "@/lib/format"; @@ -21,25 +22,73 @@ const PAYGRADES: { value: PaygradeType; label: string }[] = [ { value: "F", label: "F – Bereichsleitung / GF" }, ]; -export function PromotePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) { +// 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 +// bei der Versetzung. +type Stellenwahl = "selbe" | "neue"; + +export function PromotePanel({ + open, + onClose, + employee, + openPositions, +}: { + open: boolean; + onClose: () => void; + employee: EmployeeRow; + openPositions: OpenPositionResolved[]; +}) { const { showToast } = useToast(); const router = useRouter(); const [effectiveDate, setEffectiveDate] = useState(""); const [newTitle, setNewTitle] = useState(employee.job_title); const [paygrade, setPaygrade] = useState(employee.paygrade); + const [stellenwahl, setStellenwahl] = useState("selbe"); + 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 Stelle wählen schlägt ihre Tätigkeit als neue Bezeichnung vor. + * + * Nur ein Vorschlag: `employees.job_title` darf von der Planstelle + * abweichen, und wer eine eigene Bezeichnung tippt, soll sie behalten. + * Deshalb wird nur überschrieben, solange das Feld unverändert auf dem + * alten Titel steht. + */ + function stelleWaehlen(id: string) { + setPositionId(id); + const stelle = openPositions.find((p) => p.id === id); + if (stelle && newTitle === employee.job_title) setNewTitle(stelle.title); + } + async function handleSubmit() { if (!effectiveDate || !newTitle) { showToast("Bitte alle Pflichtfelder ausfüllen.", "error"); return; } + if (stellenwahl === "neue" && !positionId) { + showToast("Bitte die neue Planstelle auswählen.", "error"); + return; + } setPending(true); const result = await promoteEmployee({ employee_id: employee.id, effective_date: effectiveDate, new_title: newTitle, new_paygrade: paygrade, + target_position_id: stellenwahl === "neue" ? positionId : undefined, }); setPending(false); if (result.success) { @@ -72,9 +121,59 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo >
+ +
+ Planstelle +
+ + +
+
+ + {stellenwahl === "neue" && ( + <> + + {selected && ( +
+
{selected.title}
+
{selected.orgLabel}
+
+ {selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"} + {selected.managerName ? ` · berichtet an ${selected.managerName}` : ""} +
+
+ )} + + )} + setPaygrade(v as PaygradeType)} options={PAYGRADES} diff --git a/db/migrations/20260924100000_befoerderung_mit_planstelle.sql b/db/migrations/20260924100000_befoerderung_mit_planstelle.sql new file mode 100644 index 0000000..1f345c6 --- /dev/null +++ b/db/migrations/20260924100000_befoerderung_mit_planstelle.sql @@ -0,0 +1,328 @@ +-- Die Beförderung kann die Planstelle wechseln — und der Nachtlauf führt +-- beides aus. +-- +-- ═══ 1. Beförderung: dieselbe Stelle oder eine neue ════════════════ +-- +-- Bisher änderte eine Beförderung nur Bezeichnung und Verwendungsgruppe; die +-- Planstelle blieb, wo sie war. Gewünscht ist die Wahl: dieselbe Planstelle +-- oder eine neue, und dann aus den freien — wie die Zielplanstelle bei der +-- Versetzung. +-- +-- Die neue Stelle muss frei sein, mit derselben Meldung wie dort. Ein +-- Sonderfall ist erlaubt: die Person sitzt bereits darauf. Dann ist es keine +-- Bewegung, sondern eine Beförderung auf der eigenen Stelle, und ein „bereits +-- besetzt" wäre eine Falschaussage über sie selbst. +-- +-- `job_title` kommt aus dem Formular und nicht aus dem Job der Zielstelle: +-- die Spalte darf abweichen (so steht es auch in der Importvorlage), und die +-- Oberfläche schlägt den Titel der gewählten Stelle ohnehin vor. +-- +-- Nebenbei zwei Altlasten dieser Funktion, die beim Neuschreiben ohnehin +-- anfielen: `auth.uid()` weicht `app_current_user_id()` — die Anwendungsrolle +-- darf das Schema `auth` nicht —, und der `search_path` steht jetzt fest. +-- +-- ═══ 2. Der Nachtlauf bewegte niemanden ════════════════════════════ +-- +-- Beim Aufräumen dieser Stelle ist ein zweiter, älterer Fehler aufgefallen. +-- +-- `transfer_employee` schreibt für ein künftiges Datum einen Eintrag in +-- pending_org_changes mit `target_position_id`. Der Nachtlauf hat dort zur +-- Umstellung auf Planstellen (20260727120200) die Besetzung umgehängt. In der +-- zuletzt gültigen Fassung stand nur noch: +-- +-- update employees set job_title = coalesce(payload->>'new_title', ...) +-- +-- also die Fassung von **vor** jener Umstellung — und `new_title` kommt in +-- diesem payload gar nicht vor. Eine auf später datierte Versetzung wurde am +-- Stichtag auf `applied` gesetzt und bewegte niemanden: die Person blieb auf +-- ihrer alten Planstelle, im Organigramm unverändert, ohne dass irgendwo eine +-- Meldung entstand. +-- +-- Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage +-- neu geschrieben. Dieselbe Falle wie bei der Gruppe `role` +-- (20260915120000) und bei der Anmerkung zum Austritt (20260923180000). Die +-- Selbstprüfung unten prüft deshalb nicht mehr nur das neue Verhalten, +-- sondern beide Zweige auf das, was sie anfassen müssen. + +create or replace function promote_employee(payload jsonb) +returns void +language plpgsql +set search_path to 'public', 'pg_temp' +as $$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date); + v_position uuid := nullif(payload->>'target_position_id', '')::uuid; + v_old_paygrade paygrade_type; + v_name text; + v_details text; + v_besetzt uuid; + v_nummer text; +begin + perform require_hr_admin(); + select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name + from employees where id = v_employee_id; + + if v_position is not null then + select position_number into v_nummer from om_positions where id = v_position; + if v_nummer is null then + raise exception 'Die gewählte Planstelle existiert nicht.'; + end if; + + -- Wie bei der Versetzung. Die eigene Stelle ist ausgenommen: darauf sitzt + -- die Person schon, und das ist kein Hindernis für ihre Beförderung. + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_position and pa.valid_to is null; + if v_besetzt is not null and v_besetzt <> v_employee_id then + raise exception 'Die Zielplanstelle ist bereits besetzt.'; + end if; + end if; + + v_details := 'Neue Position: ' || (payload->>'new_title'); + if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then + v_details := v_details || ', neue Verwendungsgruppe: ' || (payload->>'new_paygrade'); + end if; + if v_nummer is not null then + v_details := v_details || ', neue Planstelle ' || v_nummer; + end if; + + if v_effective_date <= current_date then + update employees set + job_title = payload->>'new_title', + paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_employee_id; + + -- Nur, wenn sich die Stelle wirklich ändert: sonst entstünde eine + -- Besetzung, die am selben Tag endet und beginnt, und die Historie der + -- Planstelle hätte eine Naht ohne Anlass. + if v_position is not null and v_besetzt is distinct from v_employee_id then + update position_assignments set valid_to = v_effective_date + where employee_id = v_employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values (v_position, v_employee_id, v_effective_date); + end if; + else + insert into pending_org_changes (employee_id, change_type, effective_date, payload) + values (v_employee_id, 'promotion', v_effective_date, + jsonb_build_object( + 'new_title', payload->>'new_title', + 'new_paygrade', payload->>'new_paygrade', + 'target_position_id', v_position)); + end if; + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_effective_date, 'Beförderung', v_details); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details); +end; +$$; + +grant execute on function public.promote_employee(payload jsonb) to alpenwerk_app; + + +CREATE OR REPLACE FUNCTION public.apply_due_pending_changes() + RETURNS integer + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ +declare + v_rec record; + v_count integer := 0; + v_role jsonb; +begin + for v_rec in + select * from pending_org_changes + where status = 'pending' and effective_date <= current_date + order by effective_date, created_at + loop + if v_rec.change_type = 'transfer' then + -- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen + -- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt + -- — die Fassung von vor der Umstellung auf Planstellen. Eine auf + -- später datierte Versetzung galt damit am Stichtag als angewendet + -- und bewegte niemanden. + update position_assignments set valid_to = v_rec.effective_date + where employee_id = v_rec.employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date); + update employees set job_title = ( + select j.title from om_positions p join jobs j on j.id = p.job_id + where p.id = (v_rec.payload->>'target_position_id')::uuid + ) where id = v_rec.employee_id; + + elsif v_rec.change_type = 'promotion' then + update employees set + job_title = coalesce(v_rec.payload->>'new_title', job_title), + paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_rec.employee_id; + -- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig + -- stünde am Stichtag der neue Titel an der Person und sie säsze + -- weiterhin auf der alten Stelle — im Organigramm unverändert. + if nullif(v_rec.payload->>'target_position_id', '') is not null then + update position_assignments set valid_to = v_rec.effective_date + where employee_id = v_rec.employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date); + end if; + + elsif v_rec.change_type = 'karenz_start' then + update employees set + status = 'Karenz', + karenz_return_date = (v_rec.payload->>'planned_return_date')::date, + absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_return' then + update employees set + status = 'Aktiv', + karenz_return_date = null, + karenz_start_date = null, + absence_type = null, + employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours), + -- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am + -- Stichtag mit reduzierten Stunden zurück, und der Grund dafür + -- wäre verschwunden. + teilzeit_art = case when v_rec.payload ? 'teilzeit_art' + then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end, + teilzeit_bis = case when v_rec.payload ? 'teilzeit_art' + then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'contract_change' then + v_role := v_rec.payload->'role'; + update employees set + first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name), + last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name), + gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender), + birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date), + sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end, + nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality), + address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end, + postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end, + city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end, + address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end, + email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end, + phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end, + company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end, + employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours), + contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type), + contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date' + then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date + else contract_end_date end, + worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type), + mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart), + source = coalesce((nullif(v_role->>'source', ''))::source_type, source), + collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement), + work_days = case when v_role ? 'work_days' + then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}') + else work_days end, + is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat), + has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen), + is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung), + is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level), + dienstwagen_art = case + when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then + coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner') + else null + end, + teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end, + teilzeit_bis = case + when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null + when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date + else teilzeit_bis + end, + hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel), + aufenthaltstitel_bis = case + when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then + case when v_rec.payload->'person' ? 'aufenthaltstitel_bis' + then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date + else aufenthaltstitel_bis end + else null + end, + has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz), + kuendigungsschutz_grund = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end + else null + end, + kuendigungsschutz_ab = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end + else null + end, + kuendigungsschutz_bis = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end + else null + end, + ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert), + behinderung_grad = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end + else null + end, + behinderung_ab = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end + else null + end, + behinderung_bis = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end + else null + end + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'dependent_add' then + insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date) + values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name', + (v_rec.payload->>'relationship')::text, + nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date); + + elsif v_rec.change_type = 'dependent_remove' then + delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid; + end if; + + update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id; + v_count := v_count + 1; + end loop; + + return v_count; +end; +$function$; + +grant execute on function public.apply_due_pending_changes() to alpenwerk_app; + + +-- Selbstprüfung. +do $$ +declare + v_pro text := pg_get_functiondef('public.promote_employee(jsonb)'::regprocedure); + v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure); +begin + if v_pro not like '%target_position_id%' then + raise exception 'promote_employee kennt die Zielplanstelle nicht.'; + end if; + if v_pro like '%auth.uid()%' then + raise exception 'promote_employee ruft wieder auth.uid() — die Anwendungsrolle darf das Schema auth nicht.'; + end if; + if position('SET search_path' in v_pro) = 0 then + raise exception 'promote_employee hat keinen festen search_path.'; + end if; + + -- Beide Zweige müssen die Besetzung anfassen. Genau das ist hier schon + -- einmal still verlorengegangen; eine Prüfung auf „kommt vor" reicht + -- nicht, weil die Funktion an anderer Stelle ohnehin damit arbeitet. + if v_lauf not like '%target_position_id%' then + raise exception 'Der Nachtlauf kennt die Zielplanstelle nicht — geplante Versetzungen bewegten niemanden.'; + end if; + if (length(v_lauf) - length(replace(v_lauf, 'insert into position_assignments', ''))) / length('insert into position_assignments') < 2 then + raise exception 'Der Nachtlauf haengt die Besetzung nicht in beiden Zweigen um (Versetzung und Befoerderung).'; + end if; +end +$$;