diff --git a/actions/employees.ts b/actions/employees.ts index 6718b27..e02e0c0 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -267,6 +267,15 @@ export async function rehireEmployee(payload: { employee_id: string; rehire_date: string; position_id: string; + /** + * Die Stammdaten, wie sie der Assistent zeigt — alle freiwillig. + * + * Fehlt ein Schlüssel, bleibt der bestehende Wert stehen. Das hält den + * schlanken Aufruf (nur Datum und Planstelle) am Leben und erlaubt dem + * Assistenten trotzdem, den ganzen Satz zu schicken, ohne dass danach eine + * zweite Transaktion nötig wäre — siehe Migration 20260917130000. + */ + [feld: string]: unknown; }): Promise { try { await withUser(await currentUserId(), async (tx) => { diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index d8e7d62..9740c2c 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -16,7 +16,7 @@ import type { Database } from "@/lib/types"; import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { KarenzPanel } from "./panels/KarenzPanel"; import { PromotePanel } from "./panels/PromotePanel"; -import { RehirePanel } from "./panels/RehirePanel"; +import { RehireWizard } from "../hire/RehireWizard"; import { TerminatePanel } from "./panels/TerminatePanel"; import { TransferPanel } from "./panels/TransferPanel"; import { HistorieTab } from "./tabs/HistorieTab"; @@ -239,12 +239,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) { status={status} directReportCount={directReports.length} /> - setPanel(null)} - employee={employee} - openPositions={openPositions} - /> + {/* Der `key` sorgt fürs Zurücksetzen: beim Öffnen entsteht der + Assistent neu und liest die Vorbelegung frisch aus der Akte. Ohne + ihn trüge er nach einem Abbruch die halb ausgefüllten Angaben des + vorigen Anlaufs. */} + {panel === "rehire" && ( + setPanel(null)} + employee={employee} + openPositions={openPositions} + locations={locations} + /> + )} ); } diff --git a/components/employees/panels/RehirePanel.tsx b/components/employees/panels/RehirePanel.tsx deleted file mode 100644 index 4e865a0..0000000 --- a/components/employees/panels/RehirePanel.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { rehireEmployee } 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 { fmtDate, fmtName } from "@/lib/format"; -import type { OpenPositionResolved } from "@/lib/positions"; -import type { Database } from "@/lib/types"; - -type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; - -// 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 || !positionId) { - showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error"); - return; - } - setPending(true); - 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.`); - router.refresh(); - onClose(); - } else { - showToast(result.error ?? "Fehler beim Speichern.", "error"); - } - } - - return ( - - - - - } - > -
-
-

Letzte Position

-

{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/components/hire/RehireWizard.tsx b/components/hire/RehireWizard.tsx new file mode 100644 index 0000000..388f877 --- /dev/null +++ b/components/hire/RehireWizard.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { rehireEmployee } from "@/actions/employees"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { useToast } from "@/components/ui/Toast"; +import { fmtName } from "@/lib/format"; +import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz"; +import type { OpenPositionResolved } from "@/lib/positions"; +import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr"; +import type { Database } from "@/lib/types"; +import { vorbelegungAus } from "@/lib/wiedereintritt"; +import { StepNotfallkontakt } from "./StepNotfallkontakt"; +import { StepPerson } from "./StepPerson"; +import { StepPosition } from "./StepPosition"; +import { StepSummary } from "./StepSummary"; +import { StepVertrag } from "./StepVertrag"; +import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types"; + +type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; + +// Der Wiedereintritt als Assistent — derselbe Weg wie eine Neueinstellung, +// nur vorbefüllt. +// +// ── Warum nicht der kleine Dialog von vorher ──────────────────────── +// +// Der fragte Datum und Planstelle und liess alles andere stehen, wie es beim +// Austritt war. Nach zwei Jahren Abwesenheit ist das selten noch richtig: die +// Anschrift, die Wochenstunden, der Kollektivvertrag, oft auch der Name. Wer +// das bemerkte, musste die Person erst wiedereinstellen und danach „Daten +// ändern" öffnen — zwei Vorgänge für einen, und in der Akte steht dann eine +// Vertragsänderung am Tag des Wiedereintritts, die niemand vorgenommen hat. +// +// ── Warum ein eigenes Bauteil und kein Schalter im HireWizard ─────── +// +// Die beiden Abläufe sehen gleich aus und sind es nicht: hier gibt es keinen +// Entwurf zu speichern, keine Angehörigen anzulegen (die stehen schon in der +// Akte), keine Personalnummer zu prüfen, und am Ende ruft er eine andere +// Funktion. Ein HireWizard mit `modus === "rehire"` an sechs Stellen wäre +// schwerer zu lesen als zwei Abläufe, die sich die Schritte teilen — und die +// Schritte sind der Teil, der wirklich geteilt gehört. +// +// ── Warum alles in einem Aufruf geht ──────────────────────────────── +// +// `rehire_employee` nimmt den ganzen Satz entgegen und schreibt ihn in einer +// Transaktion (Migration 20260917130000). Erst wiedereinstellen und dann +// ändern wären zwei Transaktionen, und scheitert die zweite, steht die Person +// wieder im Dienst — mit den Daten von damals und ohne dass es jemand merkt. + +const STEP_LABELS = ["Person", "Position", "Vertrag", "Notfallkontakt", "Zusammenfassung"] as const; + +type RehireWizardProps = { + open: boolean; + onClose: () => void; + employee: EmployeeRow; + openPositions: OpenPositionResolved[]; + locations: { id: string; name: string; country: string }[]; +}; + +export function RehireWizard({ open, onClose, employee, openPositions, locations }: RehireWizardProps) { + const { showToast } = useToast(); + const router = useRouter(); + const [step, setStep] = useState(0); + // Die Akte remountet dieses Bauteil über einen wechselnden `key`, sobald es + // frisch geöffnet wird — diese Initialisierung ist damit das Zurücksetzen, + // ganz ohne Effekt. + const [draft, setDraft] = useState(() => ({ + ...EMPTY_HIRE_DRAFT, + ...vorbelegungAus(employee), + // Planstelle und Eintrittsdatum bleiben leer: die alte Stelle kann + // besetzt oder entfallen sein, und das neue Datum ist die eigentliche + // Angabe dieses Vorgangs. Siehe lib/wiedereintritt.ts. + positionId: "", + entryDate: "", + angehoerige: [], + })); + const [submitting, setSubmitting] = useState(false); + + const selectedPosition = useMemo( + () => openPositions.find((p) => p.id === draft.positionId) ?? null, + [openPositions, draft.positionId] + ); + + function update(patch: Partial) { + setDraft((prev) => ({ ...prev, ...patch })); + } + + const svNummerOk = + !draft.svNummer.trim() || + !requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) || + isValidSvnr(draft.svNummer, draft.birthDate || null); + + // Dieselben Bedingungen wie bei einer Neueinstellung, abzüglich der + // Personalnummer: sie steht fest und wird nicht geprüft. + const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = { + Person: Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk, + Position: Boolean(draft.positionId && draft.besetzung), + Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0), + Notfallkontakt: Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()), + Zusammenfassung: true, + }; + const stepValid = pruefung[STEP_LABELS[step]]; + const letzterSchritt = STEP_LABELS.length - 1; + + async function handleSubmit() { + if (!draft.positionId || !draft.entryDate) return; + // Dieselbe Regel wie im Formular: der Personenkreis zieht das Kennzeichen + // mit. Was gezeigt wurde, muss auch abgeschickt werden. + const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT; + setSubmitting(true); + + const result = await rehireEmployee({ + employee_id: employee.id, + rehire_date: draft.entryDate, + position_id: draft.positionId, + + first_name: draft.firstName, + last_name: draft.lastName, + title_prefix: draft.titlePrefix, + title_suffix: draft.titleSuffix, + gender: draft.gender, + birth_date: draft.birthDate, + sv_nummer: draft.svNummer.trim(), + email: draft.email.trim(), + phone: draft.phone.trim(), + location_id: draft.locationId, + // Anschrift, Staatsbürgerschaft und Aufenthaltstitel stehen bewusst + // nicht hier: der Assistent zeigt sie nicht, und was er nicht zeigt, + // darf er nicht schicken. rehire_employee lässt jedes nicht + // übermittelte Feld unangetastet stehen. + emergency_contact_name: draft.emergencyContactName.trim(), + emergency_contact_phone: draft.emergencyContactPhone.trim(), + emergency_contact_relation: draft.emergencyContactRelation.trim(), + + employment_type: draft.employmentType, + weekly_hours: Number(draft.weeklyHours), + contract_type: draft.contractType, + contract_end_date: draft.contractEndDate, + paygrade: draft.paygrade, + source: draft.besetzung, + + worker_type: draft.workerType, + mitarbeiterart: draft.mitarbeiterart, + collective_agreement: draft.collectiveAgreement, + work_days: draft.workDays, + is_betriebsrat: draft.isBetriebsrat, + has_dienstwagen: draft.hasDienstwagen, + dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : "", + is_laterale_fuehrung: draft.isLateraleFuehrung, + is_c_level: draft.isCLevel, + + has_kuendigungsschutz: draft.hasKuendigungsschutz, + kuendigungsschutz_grund: draft.hasKuendigungsschutz ? draft.kuendigungsschutzGrund : "", + kuendigungsschutz_ab: draft.hasKuendigungsschutz ? draft.kuendigungsschutzAb : "", + kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis : "", + ist_beguenstigt_behindert: istBehindert, + behinderung_grad: istBehindert ? draft.behinderungGrad : "", + behinderung_ab: istBehindert ? draft.behinderungAb : "", + behinderung_bis: istBehindert ? draft.behinderungBis : "", + }); + + setSubmitting(false); + if (!result.success) { + showToast(result.error ?? "Fehler beim Speichern.", "error"); + return; + } + router.refresh(); + onClose(); + showToast(`${fmtName(draft.firstName, draft.lastName)} wurde wiedereingestellt.`); + } + + return ( + + +
+ {step > 0 && ( + + )} + {step < letzterSchritt && ( + + )} + {step === letzterSchritt && ( + + )} +
+ + } + > +

+ Die Angaben stammen aus der bestehenden Akte und lassen sich hier ändern. Planstelle und Eintrittsdatum sind neu + zu wählen; Angehörige stehen bereits in der Akte. +

+ +
+ {STEP_LABELS.map((label, i) => ( + + ))} +
+ + {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } +
+ ); +} diff --git a/components/hire/StepPerson.tsx b/components/hire/StepPerson.tsx index ceec9a6..a051bdc 100644 --- a/components/hire/StepPerson.tsx +++ b/components/hire/StepPerson.tsx @@ -9,17 +9,36 @@ type StepPersonProps = { update: (patch: Partial) => void; locations: { id: string; name: string; country: string }[]; /** Meldet die geprüfte Personalnummer und ihr Ergebnis — steuert „Weiter". */ - onNummerBefund: (befund: { nummer: number; vergeben: boolean }) => void; + onNummerBefund?: (befund: { nummer: number; vergeben: boolean }) => void; + /** + * Beim Wiedereintritt: die Nummer steht fest und wird nur gezeigt. + * + * Zwei Gründe. Es ist dieselbe Person und sie behält ihre Nummer — und die + * Prüfung auf Dubletten fände hier zwangsläufig einen Treffer, nämlich sie + * selbst, und sperrte das Formular mit einer Meldung, die stimmt und + * trotzdem in die Irre führt. + */ + nummerGesperrt?: boolean; }; -export function StepPerson({ draft, update, locations, onNummerBefund }: StepPersonProps) { +export function StepPerson({ draft, update, locations, onNummerBefund, nummerGesperrt }: StepPersonProps) { return (
- update({ personnelNumber })} - onBefund={onNummerBefund} - /> + {nummerGesperrt ? ( + {}} + disabled + hint="Bleibt bei einem Wiedereintritt dieselbe." + /> + ) : ( + update({ personnelNumber })} + onBefund={onNummerBefund ?? (() => {})} + /> + )}
update({ firstName })} /> update({ lastName })} /> diff --git a/db/migrations/20260917130000_wiedereintritt_mit_stammdaten.sql b/db/migrations/20260917130000_wiedereintritt_mit_stammdaten.sql new file mode 100644 index 0000000..5139e49 --- /dev/null +++ b/db/migrations/20260917130000_wiedereintritt_mit_stammdaten.sql @@ -0,0 +1,235 @@ +-- Der Wiedereintritt nimmt die Stammdaten mit. +-- +-- Aus dem Gespräch vom 17.09.2026: der Knopf „Wiedereintritt" soll den +-- Einstellungsassistenten öffnen, **vorbefüllt** mit dem, was zu der Person +-- bekannt ist. Durchklicken, ändern, bestätigen — und danach ist sie aktiv +-- oder geplant, je nach Datum. +-- +-- ═══ Warum die Funktion das Ganze übernimmt ════════════════════════ +-- +-- Bisher nahm rehire_employee drei Angaben: Person, Datum, Planstelle. Alles +-- andere blieb stehen, wie es beim Austritt war. Nach zwei Jahren Abwesenheit +-- ist das selten noch richtig — die Anschrift, die Wochenstunden, der +-- Kollektivvertrag, oft auch der Name. +-- +-- Der naheliegende Weg wäre gewesen, danach ein change_employee_data +-- hinterherzuschicken. Er ist falsch: zwei Aufrufe sind zwei Transaktionen, +-- und scheitert die zweite, steht die Person wieder im Dienst — mit den Daten +-- von damals und ohne dass jemand es merkt. Ausserdem entstünde neben dem +-- Wiedereintritt eine Vertragsänderung am selben Tag, die niemand vorgenommen +-- hat. +-- +-- Deshalb nimmt die Funktion den ganzen Satz entgegen. Ein Wiedereintritt ist +-- ein Vorgang, kein Paar von Vorgängen. +-- +-- ═══ Warum jedes Feld einzeln mit coalesce ═════════════════════════ +-- +-- Fehlt ein Schlüssel im payload, bleibt der bestehende Wert stehen. Das ist +-- nicht Bequemlichkeit, sondern Abwärtskompatibilität: der alte, schlanke +-- Dialog schickt weiterhin nur Datum und Planstelle, und er muss weiter +-- funktionieren — sonst wäre diese Migration ein Bruch für jeden Aufruf, den +-- ich hier nicht vor Augen habe. +-- +-- Die Personalnummer steht ausdrücklich **nicht** im payload. Es ist dieselbe +-- Person; sie behält ihre Nummer, und sie hier änderbar zu machen hiesse, +-- einen zweiten Weg zu schaffen, auf dem die Nummern von Loga und Interflex +-- auseinanderlaufen können. + +CREATE OR REPLACE FUNCTION public.rehire_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_date date := (payload->>'rehire_date')::date; + v_position_id uuid := (payload->>'position_id')::uuid; + v_name text; + v_alt employees%rowtype; + v_besetzt uuid; + v_schutz boolean; + v_behindert boolean; +begin + perform require_hr_admin(); + + select * into v_alt from employees where id = v_employee_id; + if v_alt.id is null then + raise exception 'Die Person existiert nicht.'; + end if; + v_name := v_alt.first_name || ' ' || v_alt.last_name; + + if v_position_id is null then + raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.'; + end if; + + -- Dieselben Prüfungen wie bei einer Neueinstellung. Sie fehlten hier, und + -- das hiess: eine Wiedereinstellung auf eine besetzte oder noch nicht + -- gültige Planstelle scheiterte erst an einem Index oder gar nicht. + declare + v_ab date; + v_bis date; + 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 Eintritt 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 Eintritt am % ist darauf nicht möglich.', v_bis, v_date; + end if; + end; + + 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; + + -- Die beiden Kennzeichen zuerst: an ihnen hängen Felder, die ohne sie nicht + -- dastehen dürfen (chk_kuendigungsschutz_grund, chk_behinderung). + v_schutz := coalesce((payload->>'has_kuendigungsschutz')::boolean, v_alt.has_kuendigungsschutz); + v_behindert := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, v_alt.ist_beguenstigt_behindert); + + update employees set + status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end, + entry_date = v_date, + exit_date = null, + exit_reason = null, + austrittsart = null, + + first_name = coalesce(payload->>'first_name', first_name), + last_name = coalesce(payload->>'last_name', last_name), + gender = coalesce((payload->>'gender')::gender_type, gender), + birth_date = coalesce((payload->>'birth_date')::date, birth_date), + sv_nummer = case when payload ? 'sv_nummer' then nullif(payload->>'sv_nummer', '') else sv_nummer end, + nationality = coalesce(payload->>'nationality', nationality), + email = case when payload ? 'email' then nullif(payload->>'email', '') else email end, + phone = case when payload ? 'phone' then nullif(payload->>'phone', '') else phone end, + address = coalesce(payload->>'address', address), + postal_code = coalesce(payload->>'postal_code', postal_code), + city = coalesce(payload->>'city', city), + address_country = coalesce(payload->>'address_country', address_country), + location_id = coalesce((payload->>'location_id')::uuid, location_id), + title_prefix = case when payload ? 'title_prefix' + then coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}') + else title_prefix end, + title_suffix = case when payload ? 'title_suffix' + then coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}') + else title_suffix end, + emergency_contact_name = case when payload ? 'emergency_contact_name' then nullif(payload->>'emergency_contact_name', '') else emergency_contact_name end, + emergency_contact_phone = case when payload ? 'emergency_contact_phone' then nullif(payload->>'emergency_contact_phone', '') else emergency_contact_phone end, + emergency_contact_relation = case when payload ? 'emergency_contact_relation' then nullif(payload->>'emergency_contact_relation', '') else emergency_contact_relation end, + hat_aufenthaltstitel = coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel), + aufenthaltstitel_bis = case + when coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) + then case when payload ? 'aufenthaltstitel_bis' then nullif(payload->>'aufenthaltstitel_bis', '')::date else aufenthaltstitel_bis end + else null + end, + + -- Die Tätigkeit kommt von der Planstelle, nicht aus dem payload: sie + -- steht am Platz, den die Person einnimmt, und nicht an ihr. + job_title = (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id), + employment_type = coalesce((payload->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((payload->>'weekly_hours')::numeric, weekly_hours), + contract_type = coalesce((payload->>'contract_type')::contract_type, contract_type), + contract_end_date = case when payload ? 'contract_end_date' then nullif(payload->>'contract_end_date', '')::date else contract_end_date end, + paygrade = coalesce((payload->>'paygrade')::paygrade_type, paygrade), + source = coalesce((payload->>'source')::source_type, source), + + worker_type = coalesce((payload->>'worker_type')::worker_type, worker_type), + mitarbeiterart = coalesce(nullif(payload->>'mitarbeiterart', ''), mitarbeiterart), + collective_agreement = coalesce((payload->>'collective_agreement')::collective_agreement, collective_agreement), + work_days = case when payload ? 'work_days' + then coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), work_days) + else work_days end, + is_betriebsrat = coalesce((payload->>'is_betriebsrat')::boolean, is_betriebsrat), + has_dienstwagen = coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen), + dienstwagen_art = case + when coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen) + then coalesce(nullif(payload->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner') + else null + end, + is_laterale_fuehrung = coalesce((payload->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung), + is_c_level = coalesce((payload->>'is_c_level')::boolean, is_c_level), + + has_kuendigungsschutz = v_schutz, + kuendigungsschutz_grund = case when v_schutz then + case when payload ? 'kuendigungsschutz_grund' then nullif(payload->>'kuendigungsschutz_grund', '') else kuendigungsschutz_grund end + else null end, + kuendigungsschutz_ab = case when v_schutz then + case when payload ? 'kuendigungsschutz_ab' then nullif(payload->>'kuendigungsschutz_ab', '')::date else kuendigungsschutz_ab end + else null end, + kuendigungsschutz_bis = case when v_schutz then + case when payload ? 'kuendigungsschutz_bis' then nullif(payload->>'kuendigungsschutz_bis', '')::date else kuendigungsschutz_bis end + else null end, + + ist_beguenstigt_behindert = v_behindert, + behinderung_grad = case when v_behindert then + case when payload ? 'behinderung_grad' then nullif(payload->>'behinderung_grad', '')::integer else behinderung_grad end + else null end, + behinderung_ab = case when v_behindert then + case when payload ? 'behinderung_ab' then nullif(payload->>'behinderung_ab', '')::date else behinderung_ab end + else null end, + behinderung_bis = case when v_behindert then + case when payload ? 'behinderung_bis' then nullif(payload->>'behinderung_bis', '')::date else behinderung_bis end + else null end + where id = v_employee_id; + + insert into position_assignments (position_id, employee_id, valid_from) + values (v_position_id, v_employee_id, v_date); + + insert into employee_history (employee_id, event_date, event_type, description) + values (v_employee_id, v_date, 'Wiedereintritt', + 'Wiedereinstellung zum ' || v_date || ' auf Planstelle ' || + (select position_number from om_positions where id = v_position_id)); + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, + 'Wiedereintritt am ' || v_date); +end; +$function$; + +grant execute on function public.rehire_employee(payload jsonb) to alpenwerk_app; + + +-- Selbstprüfung. +do $$ +declare + v_re text := pg_get_functiondef('public.rehire_employee(jsonb)'::regprocedure); + v_feld text; +begin + -- Was der Wiedereintritt schon vorher tat, muss er weiter tun. + foreach v_feld in array array['exit_date = null', 'exit_reason = null', 'austrittsart = null', + 'position_assignments', 'Wiedereintritt', 'Wiedereinstellung'] loop + if v_re not like '%' || v_feld || '%' then + raise exception 'rehire_employee macht % nicht mehr', v_feld; + end if; + end loop; + + -- Und was neu dazukommt. + foreach v_feld in array array['first_name', 'weekly_hours', 'collective_agreement', 'work_days', + 'kuendigungsschutz_grund', 'behinderung_grad', 'mitarbeiterart', 'source'] loop + if v_re not like '%' || v_feld || '%' then + raise exception 'rehire_employee nimmt % nicht entgegen', v_feld; + end if; + end loop; + + -- Die Personalnummer bleibt aussen vor: es ist dieselbe Person, und ein + -- zweiter Weg, sie zu ändern, hiesse ein zweiter Weg, sie von Loga und + -- Interflex abweichen zu lassen. + if v_re like '%personnel_number%' then + raise exception 'rehire_employee fasst die Personalnummer an'; + end if; + + if v_re like '%auth.uid()%' then + raise exception 'rehire_employee ruft wieder auth.uid()'; + end if; + if position('SET search_path' in v_re) = 0 then + raise exception 'rehire_employee hat keinen festen search_path mehr'; + end if; +end +$$; diff --git a/lib/wiedereintritt.ts b/lib/wiedereintritt.ts new file mode 100644 index 0000000..02f0646 --- /dev/null +++ b/lib/wiedereintritt.ts @@ -0,0 +1,86 @@ +import type { HireDraftData } from "@/components/hire/types"; +import { MITARBEITERART_STANDARD } from "./mitarbeiterart"; +import type { Database } from "./types"; + +type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; + +// Eine bestehende Person als Vorbelegung für den Wiedereintritt. +// +// Der Kunde wollte für die Wiedereinstellung denselben Assistenten wie für +// eine Neueinstellung, „bereits mit den hier festgelegten Daten +// prepopulated". Diese Abbildung ist genau das: die Zeile der Person in die +// Form, die der Assistent führt. +// +// ── Warum genau die Felder des Assistenten und keines mehr ────────── +// +// Der Rückgabetyp ist `Partial` und nicht ein eigener. Damit +// lässt sich hier nur eintragen, was der Assistent auch zeigt — und das ist +// die Bedingung dafür, dass er nichts verschickt, was niemand gesehen hat. +// +// Konkret fehlen Anschrift, Staatsbürgerschaft und Aufenthaltstitel: der +// Assistent fragt sie auch bei einer Neueinstellung nicht (sie werden später +// über „Daten ändern" gepflegt). `rehire_employee` lässt jedes nicht +// übermittelte Feld unangetastet stehen, sie bleiben also, wie sie sind. +// +// ── Was bewusst NICHT übernommen wird ─────────────────────────────── +// +// * **Die Planstelle.** Sie ist beim Wiedereintritt immer neu zu wählen: +// die alte kann besetzt, ausgelaufen oder ganz entfallen sein. Ein +// vorbelegter Platz, den es so nicht mehr gibt, wäre schlimmer als ein +// leeres Feld — er sieht nach einer Antwort aus. +// * **Das Eintrittsdatum.** Der alte Eintritt ist Geschichte; das neue +// Datum ist die eigentliche Angabe dieses Vorgangs. +// * **Der Austritt.** Datum, Grund und Austrittsart räumt +// `rehire_employee` weg. +// * **Die Angehörigen.** Sie stehen schon in der Akte; der Assistent würde +// sie ein zweites Mal anlegen. +// +// Die Personalnummer kommt mit, aber nur zur Anzeige: es ist dieselbe Person, +// und `rehire_employee` fasst die Spalte nicht an. + +export function vorbelegungAus(employee: EmployeeRow): Partial { + return { + personnelNumber: String(employee.personnel_number), + firstName: employee.first_name, + lastName: employee.last_name, + titlePrefix: employee.title_prefix ?? [], + titleSuffix: employee.title_suffix ?? [], + gender: employee.gender, + birthDate: employee.birth_date, + svNummer: employee.sv_nummer ?? "", + email: employee.email ?? "", + phone: employee.phone ?? "", + locationId: employee.location_id, + besetzung: employee.source, + contractType: employee.contract_type, + contractEndDate: employee.contract_end_date ?? "", + employmentType: employee.employment_type, + // Als Text, weil das Feld im Assistenten eines ist: ein leeres Zahlenfeld + // landete sonst als 0 im Entwurf. + weeklyHours: String(employee.weekly_hours), + paygrade: employee.paygrade, + workerType: employee.worker_type, + mitarbeiterart: employee.mitarbeiterart ?? MITARBEITERART_STANDARD, + collectiveAgreement: employee.collective_agreement, + workDays: employee.work_days, + isBetriebsrat: employee.is_betriebsrat, + hasDienstwagen: employee.has_dienstwagen, + // Die Antriebsart steht auf null, sobald kein Wagen da ist. Der Assistent + // führt sie trotzdem mit, damit sie beim Anhaken wieder da ist — + // „Verbrenner" ist dabei die Vorgabe, nicht eine Behauptung. + dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner", + isLateraleFuehrung: employee.is_laterale_fuehrung, + isCLevel: employee.is_c_level, + emergencyContactName: employee.emergency_contact_name ?? "", + emergencyContactPhone: employee.emergency_contact_phone ?? "", + emergencyContactRelation: employee.emergency_contact_relation ?? "", + hasKuendigungsschutz: employee.has_kuendigungsschutz, + kuendigungsschutzGrund: employee.kuendigungsschutz_grund ?? "", + kuendigungsschutzAb: employee.kuendigungsschutz_ab ?? "", + kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "", + istBeguenstigtBehindert: employee.ist_beguenstigt_behindert, + behinderungGrad: employee.behinderung_grad === null ? "" : String(employee.behinderung_grad), + behinderungAb: employee.behinderung_ab ?? "", + behinderungBis: employee.behinderung_bis ?? "", + }; +}