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:
45
actions/hireDrafts.ts
Normal file
45
actions/hireDrafts.ts
Normal file
@@ -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<string, unknown>;
|
||||
}): Promise<ActionResult & { id?: string }> {
|
||||
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<ActionResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="ml-[236px] flex flex-1 flex-col">
|
||||
<Topbar userLabel={userLabel} role={profile?.role} />
|
||||
<main className="flex-1 px-6 py-6">
|
||||
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
||||
</main>
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="ml-[236px] flex flex-1 flex-col">
|
||||
<Topbar userLabel={userLabel} role={profile?.role} canEdit={canEdit} />
|
||||
<main className="flex-1 px-6 py-6">
|
||||
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HireWizardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{kpis.map((kpi) => (
|
||||
<div key={kpi.label} className="rounded border border-border bg-white p-4">
|
||||
|
||||
67
components/dashboard/DraftsCard.tsx
Normal file
67
components/dashboard/DraftsCard.tsx
Normal file
@@ -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<string, unknown>; 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 (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Entwürfe – Neueinstellungen</h2>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{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 (
|
||||
<li key={d.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-ink">{name}</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">Gespeichert am {fmtDate(d.updated_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => openWizard(d.id)} className="text-xs font-semibold text-brand-700 hover:underline">
|
||||
Fortsetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
disabled={pending}
|
||||
aria-label="Entwurf löschen"
|
||||
className="text-ink-muted hover:text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
components/hire/HireWizard.tsx
Normal file
177
components/hire/HireWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
components/hire/HireWizardContext.tsx
Normal file
53
components/hire/HireWizardContext.tsx
Normal 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;
|
||||
}
|
||||
66
components/hire/StepPerson.tsx
Normal file
66
components/hire/StepPerson.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
components/hire/StepPosition.tsx
Normal file
73
components/hire/StepPosition.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
components/hire/StepSummary.tsx
Normal file
56
components/hire/StepSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
components/hire/StepVertrag.tsx
Normal file
97
components/hire/StepVertrag.tsx
Normal 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
44
components/hire/types.ts
Normal 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",
|
||||
};
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showToast("Der Neueinstellungs-Assistent folgt in einer späteren Ausbaustufe.", "info")}
|
||||
onClick={() => openWizard()}
|
||||
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
||||
@@ -23,16 +23,17 @@ function titleFor(pathname: string): string {
|
||||
type TopbarProps = {
|
||||
userLabel: string;
|
||||
role?: string;
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
export function Topbar({ userLabel, role }: TopbarProps) {
|
||||
export function Topbar({ userLabel, role, canEdit }: TopbarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6">
|
||||
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<NewHireButton />
|
||||
{canEdit && <NewHireButton />}
|
||||
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm">
|
||||
<span className="font-semibold text-ink">{userLabel}</span>
|
||||
{role && <span className="text-xs text-ink-muted">({role === "hr_admin" ? "HR-Admin" : "Manager"})</span>}
|
||||
|
||||
40
lib/positions.ts
Normal file
40
lib/positions.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { breadcrumbLabel, loadOrgMaps } from "./org";
|
||||
import type { Database } from "./supabase/types";
|
||||
|
||||
export type OpenPositionResolved = {
|
||||
id: string;
|
||||
position_number: string;
|
||||
title: string;
|
||||
team_id: string;
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
created_at: string;
|
||||
managerName: string | null;
|
||||
orgLabel: string;
|
||||
};
|
||||
|
||||
// Shared by the Hire Wizard (position lookup) and the Positions & Bereiche page.
|
||||
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
|
||||
const orgMaps = await loadOrgMaps(supabase);
|
||||
const { data: positions } = await supabase
|
||||
.from("positions")
|
||||
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, created_at")
|
||||
.eq("status", "open")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
const managerIds = Array.from(
|
||||
new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id)))
|
||||
);
|
||||
const { data: managers } = managerIds.length
|
||||
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", managerIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`]));
|
||||
|
||||
return (positions ?? []).map((p) => ({
|
||||
...p,
|
||||
managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null,
|
||||
orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id),
|
||||
}));
|
||||
}
|
||||
@@ -74,6 +74,7 @@ declare
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_position record;
|
||||
v_job_title text;
|
||||
v_email text;
|
||||
v_manager uuid;
|
||||
begin
|
||||
@@ -86,9 +87,11 @@ begin
|
||||
end if;
|
||||
v_team_id := v_position.team_id;
|
||||
v_division_id := v_position.division_id;
|
||||
v_job_title := coalesce(payload->>'job_title', v_position.title);
|
||||
else
|
||||
v_team_id := (payload->>'team_id')::uuid;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
|
||||
v_job_title := payload->>'job_title';
|
||||
end if;
|
||||
|
||||
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
|
||||
@@ -103,7 +106,7 @@ begin
|
||||
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
|
||||
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
|
||||
v_email, payload->>'phone',
|
||||
v_team_id, v_division_id, coalesce(payload->>'job_title', v_position.title), (payload->>'location_id')::uuid,
|
||||
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
|
||||
v_manager, 3, false,
|
||||
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
|
||||
coalesce((payload->>'weekly_hours')::numeric, 38.5),
|
||||
@@ -112,7 +115,7 @@ begin
|
||||
nullif(payload->>'contract_end_date', '')::date,
|
||||
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
|
||||
coalesce((payload->>'source')::source_type, 'Extern'),
|
||||
case when (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end,
|
||||
(case when (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
|
||||
(payload->>'entry_date')::date
|
||||
) returning id into v_id;
|
||||
|
||||
@@ -122,10 +125,10 @@ begin
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || coalesce(payload->>'job_title', v_position.title));
|
||||
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Neueinstellung', payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || (payload->>'entry_date'));
|
||||
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
@@ -376,7 +379,7 @@ begin
|
||||
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
|
||||
|
||||
update employees set
|
||||
status = case when v_rehire_date > current_date then 'Geplant' else 'Aktiv' end,
|
||||
status = (case when v_rehire_date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
|
||||
entry_date = v_rehire_date,
|
||||
exit_date = null,
|
||||
exit_reason = null,
|
||||
|
||||
Reference in New Issue
Block a user