"use client"; import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { addEmployeeDependent, hireEmployee } from "@/actions/employees"; import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts"; import { Button } from "@/components/ui/Button"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import type { OpenPositionResolved } from "@/lib/positions"; import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr"; import { StepAngehoerige } from "./StepAngehoerige"; 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"; // Vertrag vor Angehörige: was den Vertrag ausmacht — Eintritt, Arbeitstage, // Befristung — steht auf dem Papier, das vor dem Gespräch da ist. Angehörige // bringt die Person mit, oft erst am ersten Tag. Bis September 2026 stand es // andersherum, und der freiwillige Schritt lag vor dem, der die Einstellung // überhaupt trägt. const STEP_LABELS = ["Person", "Position", "Vertrag", "Angehörige", "Notfallkontakt", "Zusammenfassung"] as const; type HireWizardProps = { open: boolean; onClose: () => void; openPositions: OpenPositionResolved[]; locations: { id: string; name: string; country: string }[]; resumeDraft: { id: string; step: number; payload: Record } | null; initialPositionId?: string; }; export function HireWizard({ open, onClose, openPositions, locations, resumeDraft, initialPositionId }: HireWizardProps) { const { showToast } = useToast(); const router = useRouter(); // The parent remounts this component (via a changing `key`) each time it's // freshly opened, so these initializers — reading resumeDraft/ // initialPositionId once at mount — are the reset, no effect needed. const [step, setStep] = useState(() => resumeDraft?.step ?? 0); const [draft, setDraft] = useState(() => resumeDraft ? { ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial) } : { ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" } ); const [draftId] = useState(() => resumeDraft?.id); 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 })); } // Blocks step 1 rather than letting the hire fail at the RPC: the SVNR // trigger rejects a bad number, and by then the user is three steps on. const svNummerOk = !draft.svNummer.trim() || !requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) || isValidSvnr(draft.svNummer, draft.birthDate || null); // Am Namen des Schritts, nicht an seiner Nummer. // // Vorher war das eine Liste in derselben Reihenfolge wie STEP_LABELS, und // die beiden mussten stumm zusammenpassen. Beim Vertauschen von Vertrag und // Angehörige wäre die Prüfung stehengeblieben, wo sie war: „Weiter" im // Vertrag hätte die Angehörigen geprüft und ein leeres Eintrittsdatum // durchgelassen — bis die Datenbank es am Ende abweist. const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = { // E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne // die Prüfung hier bricht erst die Datenbank ab — nach allen Eingaben. // Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist // freiwillig, seit die Spalte NULL zulässt. Person: Boolean( draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId ) && svNummerOk, Position: Boolean(draft.positionId && draft.besetzung), Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0), // Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig // sein, sonst scheitert sie erst nach dem Anlegen der Person, und die // steht dann schon in der Datenbank. Angehörige: draft.angehoerige.every( (a) => a.firstName.trim() && a.lastName.trim() && a.birthDate && (!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null)) ), // Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die // Datenbank weist eines ohne das andere ab (chk_emergency_contact). Notfallkontakt: Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()), Zusammenfassung: true, }; const stepValid = pruefung[STEP_LABELS[step]]; /** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */ const letzterSchritt = STEP_LABELS.length - 1; async function handleSaveDraft() { const result = await saveHireDraft({ id: draftId, step, data: draft }); if (result.success) { showToast("Entwurf gespeichert."); router.refresh(); onClose(); } else { showToast(result.error ?? "Fehler beim Speichern.", "error"); } } async function handleSubmit() { if (!selectedPosition || !draft.besetzung) return; setSubmitting(true); const result = await hireEmployee({ personnel_number: Number(draft.personnelNumber), 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 || undefined, email: draft.email.trim() || undefined, phone: draft.phone || undefined, position_id: draft.positionId, location_id: draft.locationId, entry_date: draft.entryDate, contract_type: draft.contractType, contract_end_date: draft.contractType === "befristet" ? draft.contractEndDate : undefined, employment_type: draft.employmentType, weekly_hours: Number(draft.weeklyHours), paygrade: draft.paygrade, source: draft.besetzung, worker_type: draft.workerType, collective_agreement: draft.collectiveAgreement, work_days: draft.workDays, is_betriebsrat: draft.isBetriebsrat, has_dienstwagen: draft.hasDienstwagen, // Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe // sonst nicht zu. dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null, has_kuendigungsschutz: draft.hasKuendigungsschutz, // Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu. kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null, emergency_contact_name: draft.emergencyContactName.trim() || undefined, emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined, emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined, is_laterale_fuehrung: draft.isLateraleFuehrung, is_c_level: draft.isCLevel, }); if (!result.success || !result.employeeId) { setSubmitting(false); showToast(result.error ?? "Fehler beim Anlegen.", "error"); return; } // Angehörige erst jetzt: add_employee_dependent braucht die Kennung, und // die entsteht mit der Einstellung. // // Damit hängen sie ausserhalb der Transaktion, in der die Person // entsteht. Scheitert eine, ist die Person trotzdem angelegt — deshalb // wird nicht stillschweigend weitergemacht, sondern genau gesagt, wer // fehlt. Nachtragen geht in der Personalakte. const gescheitert: string[] = []; for (const a of draft.angehoerige) { const r = await addEmployeeDependent({ employee_id: result.employeeId, first_name: a.firstName.trim(), last_name: a.lastName.trim(), relationship: a.relationship, birth_date: a.birthDate, sv_nummer: a.svNummer.trim() || undefined, effective_date: draft.entryDate, }); if (!r.success) gescheitert.push(`${a.firstName} ${a.lastName}`.trim()); } setSubmitting(false); if (draftId) await deleteHireDraft(draftId); router.refresh(); onClose(); if (gescheitert.length > 0) { showToast( `${draft.firstName} ${draft.lastName} wurde eingestellt, aber ${gescheitert.join(", ")} konnte nicht als Angehörige:r angelegt werden — bitte in der Personalakte nachtragen.`, "error" ); } else { showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`); } } return (
{step > 0 && ( )} {step < letzterSchritt && ( )} {step === letzterSchritt && ( )}
} > {/* A progress trail, not navigation: only completed steps are reachable, so the rest are genuinely disabled rather than inert buttons that silently swallow a click. */}
{STEP_LABELS.map((label, i) => ( ))}
{step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && } {step === 5 && }
); }