Files
alpenwerk-hr/tests/components/TerminatePanel.test.tsx
Maximilian Stubhan f85731dde5
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m42s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m35s
Give exits their own checklist, next to the entry one
The offboarding list was a checkbox fieldset inside the exit panel — four
items, never sent anywhere. Nothing in terminateEmployee's payload
carried them; ticking a box there recorded exactly nothing.

It's replaced with the same kind of list the entries got: its own tab,
appearing the moment an exit is recorded, with one item per row, a
comment on each, and — unlike the fieldset — a record of who touched it
and when.

The eleven items come from the same printed sheet as the entry list.
Nine are plain checkboxes. Two are text fields under "Vermerke":
remaining vacation and the balance transferred for payout — the sheet
names "Überleitung Salden für Auszahlung" twice, once as a task to do
and once as the actual figure, and those are genuinely two different
questions, kept as two items. Where the sheet still says "GKK" rather
than today's "ÖGK", it's left as written — that's the name the process
runs under internally, not a typo.

No Show gets no list. Never having worked a single day, there's no IT
access to revoke, no GKK registration to undo, no Dienstzettel to
collect — an empty checklist there would be a label with nothing behind
it. Both the tab and the auto-creation on exit check for this
specifically, not just the "Ausgetreten" status that No Show shares with
a real exit. Rehiring the same person hides the tab again — the data
stays, since it happened, but a checklist for someone currently working
has nothing to point at.

The engine (what counts as done, how progress is computed) moved into
lib/checklist.ts so onboarding and offboarding can't drift into two
different ideas of "done" the way two independent copies eventually do;
lib/onboarding.ts and lib/offboarding.ts bind it to their own item list,
and the tab UI is a single ChecklistPanel bound the same way.

Checked against the real database: a real exit creates all eleven items
in the same transaction as the exit itself; a No Show creates none;
rehiring flips the tab off while the old answers stay queryable. One
false alarm during that check turned out to be the user's own clicks on
a real employee's onboarding list, made in the browser while trying the
earlier feature — left untouched, not test debris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:25:58 +02:00

186 lines
7.0 KiB
TypeScript

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { TerminatePanel } from "@/components/employees/panels/TerminatePanel";
import { ToastProvider } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
vi.mock("@/actions/employees", () => ({
terminateEmployee: vi.fn(async () => ({ success: true })),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
// Wer nie angetreten ist, scheidet am Tag seines Eintritts aus. Nur so gibt es
// keinen Stichtag, an dem die Person als beschäftigt zählt — „nie aktiv"
// folgt aus dem Datum, nicht aus einer Sonderregel im Auswerten.
const EINTRITT = "2026-09-10";
function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
return {
id: "e1",
personnel_number: 3038,
first_name: "Hannah",
last_name: "Winkler",
gender: "w",
birth_date: "1990-04-01",
sv_nummer: null,
nationality: "Österreich",
hat_aufenthaltstitel: false,
aufenthaltstitel_bis: null,
address: null,
address_country: null,
postal_code: null,
city: null,
email: null,
phone: null,
job_title: "IT-Security-Spezialist:in",
location_id: "l1",
employment_type: "Vollzeit",
weekly_hours: 38.5,
monthly_salary_gross: null,
contract_type: "unbefristet",
contract_end_date: null,
paygrade: "C",
source: "Extern",
status: "Geplant",
entry_date: EINTRITT,
exit_date: null,
exit_reason: null,
karenz_start_date: null,
karenz_return_date: null,
absence_type: null,
avatar_color: null,
worker_type: "Angestellte:r",
collective_agreement: "Handel",
work_days: ["Mo", "Di", "Mi", "Do", "Fr"],
is_betriebsrat: false,
has_dienstwagen: false,
dienstwagen_art: null,
has_kuendigungsschutz: false,
kuendigungsschutz_bis: null,
teilzeit_art: null,
teilzeit_bis: null,
is_laterale_fuehrung: false,
is_c_level: false,
title_prefix: [],
title_suffix: [],
emergency_contact_name: null,
emergency_contact_phone: null,
emergency_contact_relation: null,
created_at: "2026-08-01T00:00:00.000Z",
updated_at: "2026-08-01T00:00:00.000Z",
...teil,
} as EmployeeRow;
}
function zeige(teil: Partial<EmployeeRow> = {}) {
return render(
<ToastProvider>
<TerminatePanel open onClose={() => {}} employee={mitarbeiter(teil)} directReportCount={0} />
</ToastProvider>
);
}
describe("TerminatePanel — Nichtantritt", () => {
it("bietet No Show als Beendigungsart an", () => {
zeige();
expect(screen.getByRole("option", { name: "No Show (nicht angetreten)" })).toBeInTheDocument();
});
it("lässt das Datum bei einem gewöhnlichen Austritt frei", () => {
zeige({ status: "Aktiv" });
const feld = screen.getByLabelText(/Austrittsdatum/);
expect(feld).toBeEnabled();
expect(feld).toHaveValue("");
});
it("setzt das Datum bei No Show auf den Eintrittstag und sperrt es", async () => {
const user = userEvent.setup();
zeige();
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
const feld = screen.getByLabelText(/Wirksam am/);
expect(feld).toHaveValue(EINTRITT);
expect(feld).toBeDisabled();
});
it("schickt den Eintrittstag, auch wenn vorher ein anderes Datum dastand", async () => {
const user = userEvent.setup();
const { terminateEmployee } = await import("@/actions/employees");
zeige({ status: "Aktiv" });
// Erst ein Datum eintippen, dann auf No Show wechseln.
await user.type(screen.getByLabelText(/Austrittsdatum/), "2026-12-31");
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
await user.click(screen.getByRole("button", { name: "Austritt bestätigen" }));
expect(terminateEmployee).toHaveBeenCalledWith(
expect.objectContaining({ exit_date: EINTRITT, exit_reason: "No Show" })
);
});
it("kündigt die Offboarding-Checkliste nur für einen echten Austritt an", async () => {
// Die Liste selbst entsteht jetzt in derselben Transaktion wie der
// Austritt (actions/employees.ts) und lebt im Reiter „Offboarding" — hier
// steht nur die Ankündigung. Bei No Show fehlt sie: die Person hat nie
// angefangen, es gibt nichts offzuboarden.
const user = userEvent.setup();
zeige({ status: "Aktiv" });
expect(screen.getByText(/Reiter „Offboarding“/)).toBeVisible();
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
expect(screen.queryByText(/Reiter „Offboarding“/)).not.toBeInTheDocument();
});
it("sagt, was mit der Planstelle passiert", async () => {
const user = userEvent.setup();
zeige();
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
expect(screen.getByText(/Planstelle wird wieder frei/)).toBeInTheDocument();
});
});
// Besonderer Kündigungsschutz: Betriebsrat, Mutterschutz, Karenz, begünstigte
// Behinderung, Lehre. Die Anwendung entscheidet nicht, ob eine Beendigung
// zulässig ist — sie darf es aber auch nicht verschweigen. Im Vertragsblatt
// nachzusehen ist genau der Schritt, den man unter Zeitdruck auslässt.
describe("TerminatePanel — Kündigungsschutz", () => {
it("warnt mit dem Enddatum, wenn eines erfasst ist", () => {
zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: "2027-06-30" });
expect(screen.getByText(/Achtung: besonderer Kündigungsschutz bis 30\.06\.2027/)).toBeInTheDocument();
});
it("warnt auch ohne Enddatum, und sagt dass keines erfasst ist", () => {
zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: null });
expect(screen.getByText(/Ende nicht erfasst/)).toBeInTheDocument();
});
it("schweigt, wo kein Schutz besteht", () => {
zeige();
expect(screen.queryByText(/besonderer Kündigungsschutz/i)).not.toBeInTheDocument();
});
it("warnt auch bei einem Nichtantritt — der Schutz gilt ab Vertragsbeginn", async () => {
const user = userEvent.setup();
zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: "2027-06-30" });
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
expect(screen.getByText(/Achtung: besonderer Kündigungsschutz/)).toBeInTheDocument();
});
});
describe("TerminatePanel — geplanter Eintritt", () => {
it("schlägt bei einem geplanten Eintritt No Show vor", () => {
zeige({ status: "Geplant" });
expect(screen.getByLabelText("Beendigungsart")).toHaveValue("No Show");
expect(screen.getByRole("heading", { name: "Nicht angetreten" })).toBeInTheDocument();
});
it("bleibt bei einer aktiven Person beim gewöhnlichen Austritt", () => {
zeige({ status: "Aktiv" });
expect(screen.getByLabelText("Beendigungsart")).toHaveValue("Einvernehmliche Auflösung");
expect(screen.getByRole("heading", { name: "Austritt" })).toBeInTheDocument();
});
});