Files
alpenwerk-hr/components/hire/StepPosition.tsx
Maximilian Stubhan f91a69147e 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.
2026-07-13 22:37:59 +02:00

74 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}