Phase 3: Hire wizard, draft resume, and two real SQL bugfixes

- 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.
This commit is contained in:
2026-07-13 22:37:59 +02:00
parent 366731ec85
commit f91a69147e
15 changed files with 766 additions and 24 deletions

View File

@@ -0,0 +1,177 @@
"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>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import type { OpenPositionResolved } from "@/lib/positions";
import { HireWizard } from "./HireWizard";
type Location = { id: string; name: string; country: string };
type HireDraft = { id: string; step: number; payload: Record<string, unknown> };
type HireWizardContextValue = {
openWizard: (draftId?: string) => void;
};
const HireWizardContext = createContext<HireWizardContextValue | null>(null);
export function HireWizardProvider({
children,
openPositions,
locations,
drafts,
}: {
children: ReactNode;
openPositions: OpenPositionResolved[];
locations: Location[];
drafts: HireDraft[];
}) {
const [open, setOpen] = useState(false);
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
function openWizard(draftId?: string) {
setResumeDraft(draftId ? (drafts.find((d) => d.id === draftId) ?? null) : null);
setOpen(true);
}
return (
<HireWizardContext.Provider value={{ openWizard }}>
{children}
<HireWizard
open={open}
onClose={() => setOpen(false)}
openPositions={openPositions}
locations={locations}
resumeDraft={resumeDraft}
/>
</HireWizardContext.Provider>
);
}
export function useHireWizard(): HireWizardContextValue {
const ctx = useContext(HireWizardContext);
if (!ctx) throw new Error("useHireWizard must be used within a HireWizardProvider");
return ctx;
}

View File

@@ -0,0 +1,66 @@
import type { HireDraftData } from "./types";
type StepPersonProps = {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
locations: { id: string; name: string; country: string }[];
};
export function StepPerson({ draft, update, locations }: StepPersonProps) {
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Vorname*</label>
<input value={draft.firstName} onChange={(e) => update({ firstName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Nachname*</label>
<input value={draft.lastName} onChange={(e) => update({ lastName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Geschlecht*</label>
<select
value={draft.gender}
onChange={(e) => update({ gender: e.target.value as HireDraftData["gender"] })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="m">männlich</option>
<option value="w">weiblich</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Geburtsdatum*</label>
<input type="date" value={draft.birthDate} onChange={(e) => update({ birthDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">SV-Nummer</label>
<input value={draft.svNummer} onChange={(e) => update({ svNummer: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label>
<input type="email" value={draft.email} onChange={(e) => update({ email: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Telefon</label>
<input value={draft.phone} onChange={(e) => update({ phone: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Standort*</label>
<select value={draft.locationId} onChange={(e) => update({ locationId: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm">
<option value="">Bitte wählen</option>
{locations.map((l) => (
<option key={l.id} value={l.id}>
{l.name} ({l.country})
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { X } from "lucide-react";
import { Lookup } from "@/components/ui/Lookup";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
type StepPositionProps = {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
openPositions: OpenPositionResolved[];
};
export function StepPosition({ draft, update, openPositions }: StepPositionProps) {
const selected = openPositions.find((p) => p.id === draft.positionId) ?? null;
async function search(query: string): Promise<OpenPositionResolved[]> {
const q = query.toLowerCase();
return openPositions.filter((p) => p.title.toLowerCase().includes(q) || p.position_number.includes(q));
}
return (
<div className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Position*</label>
{!selected ? (
<Lookup<OpenPositionResolved>
placeholder="Positionsname oder -nummer…"
onSearch={search}
onSelect={(p) => update({ positionId: p.id })}
renderResult={(p) => (
<div>
<div className="font-semibold text-ink">{p.title}</div>
<div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</div>
</div>
)}
/>
) : (
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
<div>
<div className="text-sm font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">
{selected.position_number} · {selected.orgLabel}
</div>
</div>
<button type="button" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben">
<X className="h-4 w-4 text-ink-muted" />
</button>
</div>
)}
{openPositions.length === 0 && <p className="mt-1 text-xs text-ink-muted">Derzeit sind keine offenen Positionen vorhanden.</p>}
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Besetzung*</label>
<select
value={draft.besetzung}
onChange={(e) => update({ besetzung: e.target.value as HireDraftData["besetzung"] })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="">Bitte wählen</option>
<option value="Extern">Extern</option>
<option value="Intern">Intern</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Führungskraft</label>
<input value={selected?.managerName ?? ""} disabled className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-ink-muted" />
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { fmtDate, fmtEUR } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
const PAYGRADE_LABELS: Record<string, string> = {
A: "A Einstieg",
B: "B Qualifiziert",
C: "C Erfahren",
D: "D Spezialist:in",
E: "E Teamleitung",
F: "F Bereichsleitung / GF",
};
type StepSummaryProps = {
draft: HireDraftData;
selectedPosition: OpenPositionResolved | null;
locations: { id: string; name: string; country: string }[];
};
export function StepSummary({ draft, selectedPosition, locations }: StepSummaryProps) {
const location = locations.find((l) => l.id === draft.locationId);
const rows: [string, string][] = [
["Name", `${draft.firstName} ${draft.lastName}`],
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", fmtDate(draft.birthDate)],
["SV-Nummer", draft.svNummer || ""],
["E-Mail (privat)", draft.email || ""],
["Telefon", draft.phone || ""],
["Standort", location ? `${location.name} (${location.country})` : ""],
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : ""],
["Organisationseinheit", selectedPosition?.orgLabel ?? ""],
["Führungskraft", selectedPosition?.managerName ?? ""],
["Besetzung", draft.besetzung],
["Eintrittsdatum", fmtDate(draft.entryDate)],
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
["Bruttogehalt (14x/Jahr)", fmtEUR(Number(draft.salary))],
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
];
return (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{rows.map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
<dd className="mt-0.5 text-sm text-ink">{value}</dd>
</div>
))}
</dl>
<p className="rounded bg-info-bg px-3 py-2 text-sm text-info-text">
Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben.
</p>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import type { PaygradeType } from "@/lib/supabase/types";
import type { HireDraftData } from "./types";
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [
{ value: "A", label: "A Einstieg", description: "Berufseinsteiger:innen ohne einschlägige Erfahrung" },
{ value: "B", label: "B Qualifiziert", description: "Fachkräfte mit abgeschlossener Ausbildung" },
{ value: "C", label: "C Erfahren", description: "Mehrjährige einschlägige Berufserfahrung" },
{ value: "D", label: "D Spezialist:in", description: "Vertiefte Fachexpertise" },
{ value: "E", label: "E Teamleitung", description: "Fachliche und disziplinäre Führung eines Teams" },
{ value: "F", label: "F Bereichsleitung / GF", description: "Führung eines Bereichs bzw. Geschäftsführung" },
];
export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial<HireDraftData>) => void }) {
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
update({
employmentType: value,
weeklyHours: value === "Vollzeit" ? "38.5" : draft.weeklyHours === "38.5" ? "20" : draft.weeklyHours,
});
}
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Eintrittsdatum*</label>
<input type="date" value={draft.entryDate} onChange={(e) => update({ entryDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Vertragsart</label>
<select
value={draft.contractType}
onChange={(e) => update({ contractType: e.target.value as HireDraftData["contractType"] })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="unbefristet">unbefristet</option>
<option value="befristet">befristet</option>
</select>
</div>
</div>
{draft.contractType === "befristet" && (
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Befristet bis*</label>
<input
type="date"
value={draft.contractEndDate}
onChange={(e) => update({ contractEndDate: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Beschäftigungsausmaß</label>
<select
value={draft.employmentType}
onChange={(e) => handleEmploymentTypeChange(e.target.value as HireDraftData["employmentType"])}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="Vollzeit">Vollzeit</option>
<option value="Teilzeit">Teilzeit</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wochenstunden</label>
<input
type="number"
step="0.5"
value={draft.weeklyHours}
disabled={draft.employmentType === "Vollzeit"}
onChange={(e) => update({ weeklyHours: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Bruttogehalt/Monat (14x)*</label>
<input type="number" value={draft.salary} onChange={(e) => update({ salary: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade*</label>
<select
value={draft.paygrade}
onChange={(e) => update({ paygrade: e.target.value as PaygradeType })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
{PAYGRADES.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-ink-muted">{PAYGRADES.find((p) => p.value === draft.paygrade)?.description}</p>
</div>
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>
</div>
);
}

44
components/hire/types.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { ContractType, EmploymentType, GenderType, PaygradeType } from "@/lib/supabase/types";
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
// though both are NOT NULL on employees — added here (defaults keep them
// effectively "free" for the user, same treatment as the karenz-start gap).
export type HireDraftData = {
firstName: string;
lastName: string;
gender: GenderType;
birthDate: string;
svNummer: string;
email: string;
phone: string;
locationId: string;
positionId: string;
besetzung: "Intern" | "Extern" | "";
entryDate: string;
contractType: ContractType;
contractEndDate: string;
employmentType: EmploymentType;
weeklyHours: string;
salary: string;
paygrade: PaygradeType;
};
export const EMPTY_HIRE_DRAFT: HireDraftData = {
firstName: "",
lastName: "",
gender: "m",
birthDate: "",
svNummer: "",
email: "",
phone: "",
locationId: "",
positionId: "",
besetzung: "",
entryDate: "",
contractType: "unbefristet",
contractEndDate: "",
employmentType: "Vollzeit",
weeklyHours: "38.5",
salary: "",
paygrade: "B",
};