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", () => {