From f91a69147e951e6555e51533878b33c2c33f4fe7 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 13 Jul 2026 22:37:59 +0200 Subject: [PATCH] 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. --- actions/hireDrafts.ts | 45 +++++++ app/(app)/layout.tsx | 35 +++-- app/(app)/page.tsx | 12 ++ components/dashboard/DraftsCard.tsx | 67 ++++++++++ components/hire/HireWizard.tsx | 177 ++++++++++++++++++++++++++ components/hire/HireWizardContext.tsx | 53 ++++++++ components/hire/StepPerson.tsx | 66 ++++++++++ components/hire/StepPosition.tsx | 73 +++++++++++ components/hire/StepSummary.tsx | 56 ++++++++ components/hire/StepVertrag.tsx | 97 ++++++++++++++ components/hire/types.ts | 44 +++++++ components/shell/NewHireButton.tsx | 7 +- components/shell/Topbar.tsx | 5 +- lib/positions.ts | 40 ++++++ supabase/functions.sql | 13 +- 15 files changed, 766 insertions(+), 24 deletions(-) create mode 100644 actions/hireDrafts.ts create mode 100644 components/dashboard/DraftsCard.tsx create mode 100644 components/hire/HireWizard.tsx create mode 100644 components/hire/HireWizardContext.tsx create mode 100644 components/hire/StepPerson.tsx create mode 100644 components/hire/StepPosition.tsx create mode 100644 components/hire/StepSummary.tsx create mode 100644 components/hire/StepVertrag.tsx create mode 100644 components/hire/types.ts create mode 100644 lib/positions.ts diff --git a/actions/hireDrafts.ts b/actions/hireDrafts.ts new file mode 100644 index 0000000..66552de --- /dev/null +++ b/actions/hireDrafts.ts @@ -0,0 +1,45 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; + +type ActionResult = { success: boolean; error?: string }; + +export async function saveHireDraft(payload: { + id?: string; + step: number; + data: Record; +}): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { success: false, error: "Nicht angemeldet." }; + + if (payload.id) { + const { error } = await supabase + .from("hire_drafts") + .update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() }) + .eq("id", payload.id); + if (error) return { success: false, error: error.message }; + revalidatePath("/"); + return { success: true, id: payload.id }; + } + + const { data, error } = await supabase + .from("hire_drafts") + .insert({ created_by: user.id, step: payload.step, payload: payload.data }) + .select("id") + .single(); + if (error) return { success: false, error: error.message }; + revalidatePath("/"); + return { success: true, id: data.id }; +} + +export async function deleteHireDraft(id: string): Promise { + const supabase = await createClient(); + const { error } = await supabase.from("hire_drafts").delete().eq("id", id); + if (error) return { success: false, error: error.message }; + revalidatePath("/"); + return { success: true }; +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 1fff505..c1d2a50 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,7 +1,9 @@ import { redirect } from "next/navigation"; import type { ReactNode } from "react"; +import { HireWizardProvider } from "@/components/hire/HireWizardContext"; import { Sidebar } from "@/components/shell/Sidebar"; import { Topbar } from "@/components/shell/Topbar"; +import { loadOpenPositions } from "@/lib/positions"; import { createClient } from "@/lib/supabase/server"; export default async function AppLayout({ children }: { children: ReactNode }) { @@ -11,23 +13,30 @@ export default async function AppLayout({ children }: { children: ReactNode }) { } = await supabase.auth.getUser(); if (!user) redirect("/login"); - const { data: profile } = await supabase - .from("profiles") - .select("full_name, email, role") - .eq("id", user.id) - .single(); + const { data: profile } = await supabase.from("profiles").select("full_name, email, role").eq("id", user.id).single(); + const canEdit = profile?.role === "hr_admin"; const userLabel = profile?.full_name || profile?.email || user.email || ""; + const [openPositions, locationsRes, draftsRes] = canEdit + ? await Promise.all([ + loadOpenPositions(supabase), + supabase.from("locations").select("id, name, country").order("name"), + supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }), + ]) + : [[], { data: [] }, { data: [] }]; + return ( -
- -
- -
-
{children}
-
+ +
+ +
+ +
+
{children}
+
+
-
+ ); } diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 3f677e9..f642400 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { DraftsCard } from "@/components/dashboard/DraftsCard"; import { actionBadgeStyle } from "@/lib/colors"; import { fmtDate } from "@/lib/format"; import { createClient } from "@/lib/supabase/server"; @@ -33,6 +34,16 @@ const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Karenz-Rückke export default async function DashboardPage() { const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + const { data: drafts } = user + ? await supabase + .from("hire_drafts") + .select("id, step, payload, updated_at") + .eq("created_by", user.id) + .order("updated_at", { ascending: false }) + : { data: [] }; const today = new Date(); const todayIso = isoDate(today); @@ -152,6 +163,7 @@ export default async function DashboardPage() { return (
+ {drafts && drafts.length > 0 && }
{kpis.map((kpi) => (
diff --git a/components/dashboard/DraftsCard.tsx b/components/dashboard/DraftsCard.tsx new file mode 100644 index 0000000..7c8b3f9 --- /dev/null +++ b/components/dashboard/DraftsCard.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { deleteHireDraft } from "@/actions/hireDrafts"; +import { useHireWizard } from "@/components/hire/HireWizardContext"; +import { useToast } from "@/components/ui/Toast"; +import { fmtDate } from "@/lib/format"; + +type Draft = { id: string; step: number; payload: Record; updated_at: string }; + +export function DraftsCard({ drafts }: { drafts: Draft[] }) { + const { openWizard } = useHireWizard(); + const { showToast } = useToast(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + if (drafts.length === 0) return null; + + function handleDelete(id: string) { + startTransition(async () => { + const result = await deleteHireDraft(id); + if (result.success) { + showToast("Entwurf gelöscht."); + router.refresh(); + } else { + showToast(result.error ?? "Fehler beim Löschen.", "error"); + } + }); + } + + return ( +
+

Entwürfe – Neueinstellungen

+
    + {drafts.map((d) => { + const firstName = typeof d.payload.firstName === "string" ? d.payload.firstName : ""; + const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : ""; + const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen"; + return ( +
  • +
    + {name} + Gespeichert am {fmtDate(d.updated_at)} +
    +
    + + +
    +
  • + ); + })} +
+
+ ); +} diff --git a/components/hire/HireWizard.tsx b/components/hire/HireWizard.tsx new file mode 100644 index 0000000..ff715e1 --- /dev/null +++ b/components/hire/HireWizard.tsx @@ -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 } | 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(EMPTY_HIRE_DRAFT); + const [draftId, setDraftId] = useState(undefined); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + if (resumeDraft) { + setDraft({ ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial) }); + 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) { + 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 ( + +
+ + +
+
+ {step > 0 && ( + + )} + {step < 3 && ( + + )} + {step === 3 && ( + + )} +
+
+ } + > +
+ {STEP_LABELS.map((label, i) => ( + + ))} +
+ + {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + + ); +} diff --git a/components/hire/HireWizardContext.tsx b/components/hire/HireWizardContext.tsx new file mode 100644 index 0000000..5f585cc --- /dev/null +++ b/components/hire/HireWizardContext.tsx @@ -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 }; + +type HireWizardContextValue = { + openWizard: (draftId?: string) => void; +}; + +const HireWizardContext = createContext(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(null); + + function openWizard(draftId?: string) { + setResumeDraft(draftId ? (drafts.find((d) => d.id === draftId) ?? null) : null); + setOpen(true); + } + + return ( + + {children} + setOpen(false)} + openPositions={openPositions} + locations={locations} + resumeDraft={resumeDraft} + /> + + ); +} + +export function useHireWizard(): HireWizardContextValue { + const ctx = useContext(HireWizardContext); + if (!ctx) throw new Error("useHireWizard must be used within a HireWizardProvider"); + return ctx; +} diff --git a/components/hire/StepPerson.tsx b/components/hire/StepPerson.tsx new file mode 100644 index 0000000..62943e7 --- /dev/null +++ b/components/hire/StepPerson.tsx @@ -0,0 +1,66 @@ +import type { HireDraftData } from "./types"; + +type StepPersonProps = { + draft: HireDraftData; + update: (patch: Partial) => void; + locations: { id: string; name: string; country: string }[]; +}; + +export function StepPerson({ draft, update, locations }: StepPersonProps) { + return ( +
+
+
+ + update({ firstName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + update({ lastName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+
+
+ + +
+
+ + update({ birthDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+
+ + update({ svNummer: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+
+ + update({ email: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + update({ phone: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+
+ + +
+
+ ); +} diff --git a/components/hire/StepPosition.tsx b/components/hire/StepPosition.tsx new file mode 100644 index 0000000..8a1fd77 --- /dev/null +++ b/components/hire/StepPosition.tsx @@ -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) => 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 { + const q = query.toLowerCase(); + return openPositions.filter((p) => p.title.toLowerCase().includes(q) || p.position_number.includes(q)); + } + + return ( +
+
+ + {!selected ? ( + + placeholder="Positionsname oder -nummer…" + onSearch={search} + onSelect={(p) => update({ positionId: p.id })} + renderResult={(p) => ( +
+
{p.title}
+
+ {p.position_number} · {p.orgLabel} +
+
+ )} + /> + ) : ( +
+
+
{selected.title}
+
+ {selected.position_number} · {selected.orgLabel} +
+
+ +
+ )} + {openPositions.length === 0 &&

Derzeit sind keine offenen Positionen vorhanden.

} +
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/components/hire/StepSummary.tsx b/components/hire/StepSummary.tsx new file mode 100644 index 0000000..45ede9f --- /dev/null +++ b/components/hire/StepSummary.tsx @@ -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 = { + 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 ( +
+
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+

+ Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben. +

+
+ ); +} diff --git a/components/hire/StepVertrag.tsx b/components/hire/StepVertrag.tsx new file mode 100644 index 0000000..34fe102 --- /dev/null +++ b/components/hire/StepVertrag.tsx @@ -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) => void }) { + function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) { + update({ + employmentType: value, + weeklyHours: value === "Vollzeit" ? "38.5" : draft.weeklyHours === "38.5" ? "20" : draft.weeklyHours, + }); + } + + return ( +
+
+
+ + update({ entryDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + +
+
+ {draft.contractType === "befristet" && ( +
+ + update({ contractEndDate: e.target.value })} + className="w-full rounded border border-border px-3 py-2 text-sm" + /> +
+ )} +
+
+ + +
+
+ + 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" + /> +
+
+
+ + update({ salary: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + +

{PAYGRADES.find((p) => p.value === draft.paygrade)?.description}

+
+

Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.

+
+ ); +} diff --git a/components/hire/types.ts b/components/hire/types.ts new file mode 100644 index 0000000..8c0287c --- /dev/null +++ b/components/hire/types.ts @@ -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", +}; diff --git a/components/shell/NewHireButton.tsx b/components/shell/NewHireButton.tsx index 9c0a1cc..8610ebe 100644 --- a/components/shell/NewHireButton.tsx +++ b/components/shell/NewHireButton.tsx @@ -1,16 +1,15 @@ "use client"; import { Plus } from "lucide-react"; -import { useToast } from "@/components/ui/Toast"; +import { useHireWizard } from "@/components/hire/HireWizardContext"; -// Stub for Phase 1: the real 4-step hire wizard (§4.4) ships in a later phase. export function NewHireButton() { - const { showToast } = useToast(); + const { openWizard } = useHireWizard(); return (