diff --git a/components/dashboard/DraftsCard.tsx b/components/dashboard/DraftsCard.tsx
index fc98087..e71f3cc 100644
--- a/components/dashboard/DraftsCard.tsx
+++ b/components/dashboard/DraftsCard.tsx
@@ -50,9 +50,11 @@ export function DraftsCard({ drafts }: { drafts: Entwurf[] }) {
{name}
Gespeichert am {fmtDate(d.updated_at)}
- {/* Nur bei fremden Entwürfen. Der eigene Name stünde sonst an
- jeder Zeile und sagte nichts. */}
- {d.autor && von {d.autor}}
+ {/* An jeder Zeile, wie an den Notizen in der Glocke: seit
+ fremde Entwürfe dazwischenstehen, ist ohne die Angabe nicht
+ zu sehen, welcher wessen ist. „von mir" statt des eigenen
+ Namens — den kennt man. */}
+ {d.vonMir ? "von mir" : `von ${d.autor}`}
{d.vonMir ? (
diff --git a/components/hire/HireWizard.tsx b/components/hire/HireWizard.tsx
index 299bdf8..fb30456 100644
--- a/components/hire/HireWizard.tsx
+++ b/components/hire/HireWizard.tsx
@@ -17,7 +17,12 @@ import { StepSummary } from "./StepSummary";
import { StepVertrag } from "./StepVertrag";
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
-const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
+// Vertrag vor Angehörige: was den Vertrag ausmacht — Eintritt, Arbeitstage,
+// Befristung — steht auf dem Papier, das vor dem Gespräch da ist. Angehörige
+// bringt die Person mit, oft erst am ersten Tag. Bis September 2026 stand es
+// andersherum, und der freiwillige Schritt lag vor dem, der die Einstellung
+// überhaupt trägt.
+const STEP_LABELS = ["Person", "Position", "Vertrag", "Angehörige", "Notfallkontakt", "Zusammenfassung"] as const;
type HireWizardProps = {
open: boolean;
@@ -59,31 +64,41 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
isValidSvnr(draft.svNummer, draft.birthDate || null);
- const stepValid = [
+ // Am Namen des Schritts, nicht an seiner Nummer.
+ //
+ // Vorher war das eine Liste in derselben Reihenfolge wie STEP_LABELS, und
+ // die beiden mussten stumm zusammenpassen. Beim Vertauschen von Vertrag und
+ // Angehörige wäre die Prüfung stehengeblieben, wo sie war: „Weiter" im
+ // Vertrag hätte die Angehörigen geprüft und ein leeres Eintrittsdatum
+ // durchgelassen — bis die Datenbank es am Ende abweist.
+ const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = {
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
- // die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
- // Schritts, nach allen Eingaben.
+ // die Prüfung hier bricht erst die Datenbank ab — nach allen Eingaben.
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
// freiwillig, seit die Spalte NULL zulässt.
- Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
- svNummerOk,
- Boolean(draft.positionId && draft.besetzung),
+ Person:
+ Boolean(
+ draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId
+ ) && svNummerOk,
+ Position: Boolean(draft.positionId && draft.besetzung),
+ Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0),
// Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig
// sein, sonst scheitert sie erst nach dem Anlegen der Person, und die
// steht dann schon in der Datenbank.
- draft.angehoerige.every(
+ Angehörige: draft.angehoerige.every(
(a) =>
a.firstName.trim() &&
a.lastName.trim() &&
a.birthDate &&
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
),
- Boolean(draft.entryDate && draft.workDays.length > 0),
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
- Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
- true,
- ][step];
+ Notfallkontakt:
+ Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
+ Zusammenfassung: true,
+ };
+ const stepValid = pruefung[STEP_LABELS[step]];
/** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */
const letzterSchritt = STEP_LABELS.length - 1;
@@ -242,8 +257,8 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
{step === 0 && }
{step === 1 && }
- {step === 2 && }
- {step === 3 && }
+ {step === 2 && }
+ {step === 3 && }
{step === 4 && }
{step === 5 && }
diff --git a/lib/entwuerfe.ts b/lib/entwuerfe.ts
index 9f67dbb..c6173f1 100644
--- a/lib/entwuerfe.ts
+++ b/lib/entwuerfe.ts
@@ -34,8 +34,8 @@ export type Entwurf = {
updated_at: string;
/** Ob der Entwurf von der angemeldeten Person stammt — nur dann darf sie ihn anfassen. */
vonMir: boolean;
- /** Wer ihn angefangen hat. Leer bei den eigenen: der Name sagte dort nichts. */
- autor: string | null;
+ /** Wer ihn angefangen hat — steht an jeder Zeile, auch an den eigenen. */
+ autor: string;
};
/**
@@ -69,16 +69,15 @@ export function entwuerfeAbfrage(eb: OrgEb, userId: string) {
/** Der reine Teil: aus den Zeilen die Entwürfe mit lesbarem Verfasser. */
export function baueEntwuerfe(rows: EntwurfZeile[], userId: string | null): Entwurf[] {
return rows.map((row) => {
- const vonMir = userId !== null && row.created_by === userId;
return {
id: row.id,
step: row.step,
payload: row.payload,
updated_at: row.updated_at,
- vonMir,
- // Ohne Namen die E-Mail — sonst stünde an einem fremden Entwurf nichts
- // ausser dem Hinweis, dass er fremd ist.
- autor: vonMir ? null : row.author_name?.trim() || row.author_email || "Unbekannt",
+ vonMir: userId !== null && row.created_by === userId,
+ // Ohne Namen die E-Mail — sonst stünde an einem Entwurf nichts ausser
+ // dem Hinweis, dass er von jemandem ist.
+ autor: row.author_name?.trim() || row.author_email || "Unbekannt",
};
});
}
diff --git a/tests/components/DraftsCard.test.tsx b/tests/components/DraftsCard.test.tsx
index 0fb18b0..f3a869b 100644
--- a/tests/components/DraftsCard.test.tsx
+++ b/tests/components/DraftsCard.test.tsx
@@ -27,7 +27,7 @@ function entwurf(teil: Partial = {}): Entwurf {
payload: { firstName: "Manuel", lastName: "Aigner" },
updated_at: "2026-09-09T08:00:00.000Z",
vonMir: true,
- autor: null,
+ autor: "Max Stubhan",
...teil,
};
}
@@ -62,10 +62,12 @@ describe("DraftsCard", () => {
expect(within(z).getByRole("button", { name: "Entwurf löschen" })).toBeInTheDocument();
});
- it("nennt an eigenen Entwürfen keinen Verfasser", () => {
- // Der eigene Name stünde an jeder Zeile und sagte nichts.
+ it("sagt an eigenen Entwürfen: von mir", () => {
+ // Der eigene Name stünde an jeder Zeile und sagte nichts — man kennt ihn.
zeige([entwurf()]);
- expect(within(zeile("Manuel Aigner")).queryByText(/^von /)).not.toBeInTheDocument();
+ const z = zeile("Manuel Aigner");
+ expect(within(z).getByText("von mir")).toBeInTheDocument();
+ expect(within(z).queryByText(/Max Stubhan/)).not.toBeInTheDocument();
});
it("nennt an fremden Entwürfen, von wem sie stammen", () => {
diff --git a/tests/components/HireWizardSchritte.test.tsx b/tests/components/HireWizardSchritte.test.tsx
new file mode 100644
index 0000000..5956b00
--- /dev/null
+++ b/tests/components/HireWizardSchritte.test.tsx
@@ -0,0 +1,135 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { HireWizard } from "@/components/hire/HireWizard";
+import { ToastProvider } from "@/components/ui/Toast";
+import { EMPTY_HIRE_DRAFT } from "@/components/hire/types";
+import type { OpenPositionResolved } from "@/lib/positions";
+
+vi.mock("@/actions/employees", () => ({
+ hireEmployee: vi.fn(async () => ({ success: true })),
+ addEmployeeDependent: vi.fn(async () => ({ success: true })),
+}));
+vi.mock("@/actions/hireDrafts", () => ({
+ saveHireDraft: vi.fn(async () => ({ success: true })),
+ deleteHireDraft: vi.fn(async () => ({ success: true })),
+}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
+
+// Die Reihenfolge der Schritte und die Prüfung, die an jedem hängt, standen
+// bis September 2026 in zwei Listen, die stumm zusammenpassen mussten. Beim
+// Vertauschen von Vertrag und Angehörige wäre die Prüfung stehengeblieben,
+// wo sie war — „Weiter" im Vertrag hätte die Angehörigen geprüft und ein
+// leeres Eintrittsdatum durchgelassen, bis die Datenbank es am Ende abweist.
+// Deshalb steht beides hier: die Abfolge und der Riegel am richtigen Schritt.
+
+const POSITION = {
+ id: "p1",
+ title: "Sachbearbeiter:in",
+ orgUnitName: "Personal",
+} as unknown as OpenPositionResolved;
+
+const LOCATIONS = [{ id: "l1", name: "Wien", country: "AT" }];
+
+/** Steigt mitten im Assistenten ein — ein Entwurf ist genau dafür da. */
+function zeige(step: number, payload: Partial = {}) {
+ return render(
+
+ {}}
+ openPositions={[POSITION]}
+ locations={LOCATIONS}
+ resumeDraft={{ id: "d1", step, payload: { ...EMPTY_HIRE_DRAFT, ...payload } }}
+ />
+
+ );
+}
+
+const weiter = () => screen.getByRole("button", { name: "Weiter" });
+
+describe("Abfolge der Schritte", () => {
+ it("führt Vertrag vor Angehörige", () => {
+ zeige(0);
+ const trail = screen.getAllByRole("button").filter((b) => /^\d/.test(b.textContent ?? ""));
+ expect(trail.map((b) => b.textContent?.replace(/^\d/, ""))).toEqual([
+ "Person",
+ "Position",
+ "Vertrag",
+ "Angehörige",
+ "Notfallkontakt",
+ "Zusammenfassung",
+ ]);
+ });
+
+ it("zeigt an Schritt 3 den Vertrag", () => {
+ zeige(2);
+ expect(screen.getByLabelText(/Eintrittsdatum/)).toBeInTheDocument();
+ });
+
+ it("zeigt an Schritt 4 die Angehörigen", () => {
+ zeige(3);
+ expect(screen.getByRole("button", { name: /Angehörige:n hinzufügen/ })).toBeInTheDocument();
+ });
+
+ it("hält im Vertrag ohne Eintrittsdatum an", () => {
+ // Der Riegel muss mit dem Schritt gewandert sein. Bleibt er bei den
+ // Angehörigen, ist „Weiter" hier offen — und das Eintrittsdatum fehlt
+ // bis zum Anlegen.
+ zeige(2, { entryDate: "" });
+ expect(weiter()).toBeDisabled();
+ });
+
+ it("lässt im Vertrag mit Eintrittsdatum weiter", () => {
+ zeige(2, { entryDate: "2026-10-01" });
+ expect(weiter()).toBeEnabled();
+ });
+
+ it("lässt die Angehörigen leer durch", async () => {
+ // Freiwillig — der Schritt darf niemanden aufhalten.
+ zeige(3);
+ expect(weiter()).toBeEnabled();
+ });
+
+ it("hält bei einer angefangenen Angehörigen-Zeile an", async () => {
+ // Sie scheitert sonst erst nach dem Anlegen der Person, und die steht
+ // dann schon in der Datenbank.
+ const user = userEvent.setup();
+ zeige(3);
+ await user.click(screen.getByRole("button", { name: /Angehörige:n hinzufügen/ }));
+ expect(weiter()).toBeDisabled();
+ });
+
+ it("blättert vom Vertrag zu den Angehörigen", async () => {
+ const user = userEvent.setup();
+ zeige(2, { entryDate: "2026-10-01" });
+ await user.click(weiter());
+ expect(screen.getByRole("button", { name: /Angehörige:n hinzufügen/ })).toBeInTheDocument();
+ expect(screen.queryByLabelText(/Eintrittsdatum/)).not.toBeInTheDocument();
+ });
+
+ it("blättert von den Angehörigen zurück zum Vertrag", async () => {
+ // Der Weg zurück führt dorthin, wo man herkam — sonst springt der
+ // Assistent an eine Stelle, die niemand erwartet.
+ const user = userEvent.setup();
+ zeige(3);
+ await user.click(screen.getByRole("button", { name: "Zurück" }));
+ expect(screen.getByLabelText(/Eintrittsdatum/)).toBeInTheDocument();
+ });
+});
+
+describe("Fortschrittsleiste", () => {
+ it("markiert den Schritt, auf dem man steht", () => {
+ zeige(2);
+ const aktuell = screen.getAllByRole("button").find((b) => b.getAttribute("aria-current") === "step");
+ expect(aktuell).toHaveTextContent("Vertrag");
+ });
+
+ it("lässt nur zurück, nicht vor", () => {
+ zeige(2);
+ const trail = screen.getAllByRole("button").filter((b) => /^\d/.test(b.textContent ?? ""));
+ const nach = (label: string) => trail.find((b) => within(b).queryByText(label) ?? b.textContent?.includes(label));
+ expect(nach("Position")).toBeEnabled();
+ expect(nach("Angehörige")).toBeDisabled();
+ });
+});
diff --git a/tests/unit/entwuerfe.test.ts b/tests/unit/entwuerfe.test.ts
index b403340..6772035 100644
--- a/tests/unit/entwuerfe.test.ts
+++ b/tests/unit/entwuerfe.test.ts
@@ -97,10 +97,11 @@ describe("baueEntwuerfe", () => {
expect(e.vonMir).toBe(true);
});
- it("nennt bei eigenen Entwürfen keinen Verfasser", () => {
- // Der eigene Name stünde an jeder Zeile und sagte nichts.
- const [e] = baueEntwuerfe([zeile({ created_by: ICH })], ICH);
- expect(e.autor).toBeNull();
+ it("nennt den Verfasser auch am eigenen Entwurf", () => {
+ // Die Karte schreibt daran zwar „von mir"; die Angabe steht trotzdem
+ // bereit, damit die Zeile nicht davon abhängt, wer sie ansieht.
+ const [e] = baueEntwuerfe([zeile({ created_by: ICH, author_name: "Max Stubhan" })], ICH);
+ expect(e.autor).toBe("Max Stubhan");
});
it("nennt bei fremden Entwürfen den Namen", () => {