Files
alpenwerk-hr/components/hire/RehireWizard.tsx
Andrei Laas 57acbfae9d
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m27s
Die Firmen-E-Mail als eigenes, freiwilliges Feld
employees.email ist die private Adresse (20260811140000). Sie taugt nicht
als Dienstadresse und darf auch nicht als solche benutzt werden: der
Honestly-Export geht an einen fremden Anbieter, der damit im Namen des
Arbeitgebers einlaedt. Die Spalte "Email" stand dort deshalb seit jeher
leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im
Datenmodell fehlt. Jetzt gibt es sie, und die Spalte fuellt sich.

Eindeutig, aber freiwillig -- mehrere Personen ohne Adresse stoeren den
Index nicht, weil null nie gleich null ist. Geschrieben wird ueber
`case when ? then nullif` statt `coalesce`: eine Dienstadresse muss sich
auch wieder entfernen lassen.

Vier SQL-Funktionen mussten mit, weil `create or replace` die ganze
Fassung ersetzt und ein ausgelassenes Feld dort still verschwindet:
hire_employee und rehire_employee (beide teilen sich den Schritt
"Person" -- das Formular haette das Feld gezeigt und den Wert
weggeworfen), change_employee_data (sonst nicht aenderbar),
apply_due_pending_changes (sonst verfiele eine auf spaeter datierte
Aenderung) und die Feldkarte (sonst waere der Eintrag in der Historie
nicht korrigierbar). Die Selbstpruefung am Ende prueft jede einzeln.
2026-09-23 10:52:26 +02:00

241 lines
10 KiB
TypeScript

"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<HireDraftData>(() => ({
...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<HireDraftData>) {
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(),
company_email: draft.companyEmail.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 (
<Modal
open={open}
onClose={onClose}
title="Wiedereintritt"
widthClassName="max-w-2xl"
footer={
<div className="flex w-full items-center justify-between">
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<div className="flex gap-2">
{step > 0 && (
<Button variant="secondary" onClick={() => setStep((s) => s - 1)}>
Zurück
</Button>
)}
{step < letzterSchritt && (
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
Weiter
</Button>
)}
{step === letzterSchritt && (
<Button onClick={handleSubmit} pending={submitting}>
Wiedereinstellen
</Button>
)}
</div>
</div>
}
>
<p className="mb-4 rounded bg-surface px-3 py-2 text-sm text-ink-body">
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.
</p>
<div className="mb-6 flex items-center justify-center gap-3">
{STEP_LABELS.map((label, i) => (
<button
key={label}
type="button"
disabled={i >= step}
aria-current={i === step ? "step" : undefined}
onClick={() => i < step && setStep(i)}
className={`flex items-center gap-2 rounded text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:cursor-default ${
i === step ? "text-brand-700" : i < step ? "text-ink-body" : "text-ink-muted"
}`}
>
<span
className={`flex h-6 w-6 items-center justify-center rounded-full ${i <= step ? "bg-brand-500 text-white" : "bg-surface text-ink-muted"}`}
>
{i + 1}
</span>
{label}
</button>
))}
</div>
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} nummerGesperrt />}
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
{step === 2 && <StepVertrag draft={draft} update={update} />}
{step === 3 && <StepNotfallkontakt draft={draft} update={update} />}
{step === 4 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
</Modal>
);
}