Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`).
172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { 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;
|
|
initialPositionId?: string;
|
|
};
|
|
|
|
export function HireWizard({ open, onClose, openPositions, locations, resumeDraft, initialPositionId }: HireWizardProps) {
|
|
const { showToast } = useToast();
|
|
const router = useRouter();
|
|
// The parent remounts this component (via a changing `key`) each time it's
|
|
// freshly opened, so these initializers — reading resumeDraft/
|
|
// initialPositionId once at mount — are the reset, no effect needed.
|
|
const [step, setStep] = useState(() => resumeDraft?.step ?? 0);
|
|
const [draft, setDraft] = useState<HireDraftData>(() =>
|
|
resumeDraft
|
|
? { ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) }
|
|
: { ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" }
|
|
);
|
|
const [draftId] = useState<string | undefined>(() => resumeDraft?.id);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
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),
|
|
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),
|
|
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>
|
|
);
|
|
}
|