Positions
- Removed the "Besetzen" action, the StaffInternallyModal behind it and the
now-unreachable staffPositionInternally server action: a position is filled
through the hire process, not from the positions list. Note that
transfer_employee has no position_id at all and never touched `positions`,
so with staff_position_internally out of the UI, hire_employee is the only
thing that closes a position — a transfer into an open one leaves it open.
The RPC itself is still in the database and still covered by its tests.
SVNR
- Austrian social security numbers are now validated: ten digits, weighted
check digit mod 11, and the TTMMJJ tail cross-checked against birth_date,
which is what catches a transposed date that a valid check digit would let
through. A serial whose weighted sum lands on 11 is rejected rather than
wrapped — those are never issued.
- Applies to Austrian locations only; the German/Czech/Slovenian equivalents
have their own formats and stay free-form.
- Enforced by a trigger, not inside hire_employee/change_employee_data, for
the same reason as the assignment history: both have been redefined by
half a dozen migrations. Only a *newly written* value is checked, so a
legacy number never blocks an unrelated transfer or address change.
- The seed drew a random four-digit prefix, so its check digit was right
only by chance and every seeded Austrian row would now be rejected;
it computes the check digit properly now.
Tech stack
- next 16.2.11 closes nine advisories against 16.2.10, including a
middleware/proxy bypass in App Router apps on Turbopack — proxy.ts is this
app's entry gate. RLS remains the real boundary, so the blast radius was a
blank page rather than data, but it is a patch-level fix. Also react
19.2.8, tailwind 4.3.3, lucide-react 1.26, supabase-js/ssr, postcss.
- CI runs lint, typecheck, schema/type drift, tests and build; a second job
replays every migration onto an empty database and runs the integration
suite against it, so a migration that cannot be replayed from scratch
fails here instead of during a restore.
- scripts/check-schema-types.mjs diffs the hand-written lib/supabase/types.ts
against the migrations. Reading the SQL rather than a live database keeps
Postgres out of the fast CI job. Verified in both directions.
- vitest now runs two projects: node for logic, jsdom for components. The
first component test covers the org chart expand control, which broke
earlier this session when elementsSelectable={false} made React Flow
compute pointer-events:none for the whole node; re-introducing that prop
fails three of these tests.
- Content-Security-Policy is emitted report-only. Enforcing a policy derived
from inspection rather than from violation reports risks blanking the app;
'unsafe-inline' on script-src is required until a nonce is threaded through
proxy.ts, which is a separate change.
- Fixed supabase/seed.ts, which this session's SVNR change had broken: the
extensionless "../lib/svnr" import does not resolve under Node's ESM
loader, so the seed failed at startup.
- engines pinned to node >=22 <25, tsconfig target ES2022, and the dead
test:e2e script removed (no Playwright is installed).
189 lines
7.1 KiB
TypeScript
189 lines
7.1 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 { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
|
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 }));
|
|
}
|
|
|
|
// Blocks step 1 rather than letting the hire fail at the RPC: the SVNR
|
|
// trigger rejects a bad number, and by then the user is three steps on.
|
|
const svNummerOk =
|
|
!draft.svNummer.trim() ||
|
|
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
|
|
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
|
|
|
const stepValid = [
|
|
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
|
Boolean(draft.positionId && draft.besetzung),
|
|
Boolean(draft.entryDate && draft.workDays.length > 0),
|
|
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,
|
|
title_prefix: draft.titlePrefix,
|
|
title_suffix: draft.titleSuffix,
|
|
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,
|
|
worker_type: draft.workerType,
|
|
collective_agreement: draft.collectiveAgreement,
|
|
work_days: draft.workDays,
|
|
is_betriebsrat: draft.isBetriebsrat,
|
|
has_dienstwagen: draft.hasDienstwagen,
|
|
is_laterale_fuehrung: draft.isLateraleFuehrung,
|
|
is_c_level: draft.isCLevel,
|
|
});
|
|
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>
|
|
);
|
|
}
|