- components/hire/: 4-step Hire Wizard (Person/Position/Vertrag/ Zusammenfassung) matching sec4.4, with a HireWizardProvider context so it can be opened both from the global "+ Neueinstellung" button and from a "Fortsetzen" link on a saved draft. - actions/hireDrafts.ts: save/delete hire_drafts (owner-scoped RLS already in place from Phase 1). Dashboard now shows the "Entwuerfe" card the Phase 1 plan deferred, since the wizard it depends on now exists. - lib/positions.ts: shared open-positions loader (position number, org breadcrumb, resolved manager name) used by both the wizard and (later) the Positions page. Two real bugs found via live testing and fixed in supabase/functions.sql: 1. hire_employee/rehire_employee: a two-branch CASE returning bare string literals defaults to `text`, not the target enum, so `status = case when ... then 'Geplant' else 'Aktiv' end` failed against the employment_status column. Fixed with an explicit ::employment_status cast on the whole CASE expression. 2. Postgres precedence gotcha: ->> and || sit at the *same* precedence tier and left-associate, so `payload->>'first_name' || ' ' || payload->>'last_name'` does not group the way it reads - it tries to apply ->> to an intermediate text value and fails with "operator does not exist: text ->> unknown". Fixed by parenthesizing every ->>'...' expression that participates in a || chain. Also fixed: hire_employee referenced v_position.title outside the branch that assigns v_position, raising "record not assigned" whenever a hire wasn't tied to a position_id; extracted a v_job_title variable instead. Verified live end-to-end: wizard search -> select position -> submit creates the employee, closes the position, and writes matching employee_history + audit_log rows atomically.
178 lines
6.3 KiB
TypeScript
178 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { hireEmployee } from "@/actions/employees";
|
|
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
|
import { Modal } from "@/components/ui/Modal";
|
|
import { useToast } from "@/components/ui/Toast";
|
|
import type { OpenPositionResolved } from "@/lib/positions";
|
|
import { StepPerson } from "./StepPerson";
|
|
import { StepPosition } from "./StepPosition";
|
|
import { StepSummary } from "./StepSummary";
|
|
import { StepVertrag } from "./StepVertrag";
|
|
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
|
|
|
|
const STEP_LABELS = ["Person", "Position", "Vertrag", "Zusammenfassung"];
|
|
|
|
type HireWizardProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
openPositions: OpenPositionResolved[];
|
|
locations: { id: string; name: string; country: string }[];
|
|
resumeDraft: { id: string; step: number; payload: Record<string, unknown> } | null;
|
|
};
|
|
|
|
export function HireWizard({ open, onClose, openPositions, locations, resumeDraft }: HireWizardProps) {
|
|
const { showToast } = useToast();
|
|
const router = useRouter();
|
|
const [step, setStep] = useState(0);
|
|
const [draft, setDraft] = useState<HireDraftData>(EMPTY_HIRE_DRAFT);
|
|
const [draftId, setDraftId] = useState<string | undefined>(undefined);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
if (resumeDraft) {
|
|
setDraft({ ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) });
|
|
setStep(resumeDraft.step);
|
|
setDraftId(resumeDraft.id);
|
|
} else {
|
|
setDraft(EMPTY_HIRE_DRAFT);
|
|
setStep(0);
|
|
setDraftId(undefined);
|
|
}
|
|
}, [open, resumeDraft]);
|
|
|
|
const selectedPosition = useMemo(
|
|
() => openPositions.find((p) => p.id === draft.positionId) ?? null,
|
|
[openPositions, draft.positionId]
|
|
);
|
|
|
|
function update(patch: Partial<HireDraftData>) {
|
|
setDraft((prev) => ({ ...prev, ...patch }));
|
|
}
|
|
|
|
const stepValid = [
|
|
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
|
|
Boolean(draft.positionId && draft.besetzung),
|
|
Boolean(draft.entryDate && draft.salary),
|
|
true,
|
|
][step];
|
|
|
|
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({
|
|
first_name: draft.firstName,
|
|
last_name: draft.lastName,
|
|
gender: draft.gender,
|
|
birth_date: draft.birthDate,
|
|
sv_nummer: draft.svNummer || 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),
|
|
monthly_salary_gross: Number(draft.salary),
|
|
paygrade: draft.paygrade,
|
|
source: draft.besetzung,
|
|
});
|
|
setSubmitting(false);
|
|
if (result.success) {
|
|
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
|
|
if (draftId) await deleteHireDraft(draftId);
|
|
router.refresh();
|
|
onClose();
|
|
} else {
|
|
showToast(result.error ?? "Fehler beim Anlegen.", "error");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title="Neueinstellung"
|
|
widthClassName="max-w-2xl"
|
|
footer={
|
|
<div className="flex w-full items-center justify-between">
|
|
<div className="flex gap-2">
|
|
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
|
Abbrechen
|
|
</button>
|
|
<button onClick={handleSaveDraft} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
|
Als Entwurf speichern
|
|
</button>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{step > 0 && (
|
|
<button
|
|
onClick={() => setStep((s) => s - 1)}
|
|
className="rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
|
|
>
|
|
Zurück
|
|
</button>
|
|
)}
|
|
{step < 3 && (
|
|
<button
|
|
onClick={() => setStep((s) => s + 1)}
|
|
disabled={!stepValid}
|
|
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
|
|
>
|
|
Weiter
|
|
</button>
|
|
)}
|
|
{step === 3 && (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={submitting}
|
|
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
|
>
|
|
Anlegen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="mb-6 flex items-center justify-center gap-3">
|
|
{STEP_LABELS.map((label, i) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
onClick={() => i < step && setStep(i)}
|
|
className={`flex items-center gap-2 text-xs font-semibold ${
|
|
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} />}
|
|
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
|
|
{step === 2 && <StepVertrag draft={draft} update={update} />}
|
|
{step === 3 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
|
|
</Modal>
|
|
);
|
|
}
|