Files
alpenwerk-hr/tests/components/TerminatePanel.test.tsx
Andrei Laas 4165f3f8a1 Der Wiedereintritt war da, nur nicht erreichbar
Aus dem Gespraech vom 17.09.2026.

Der Kunde suchte den Knopf "Wiedereintritt" an einer ausgetretenen Person
und hielt ihn fuer verschwunden — "ich dachte eigentlich, das ist schon
implementiert, das war mal drin". Er war drin. Er hing nur an
employee.status, und diese Spalte haengt nach: an einer Person, deren
Austritt erfasst und inzwischen vollzogen war, stand dort weiter "Aktiv".

Das ist mein Fehler beim Statusfix. Umgestellt waren dort `isActive` und
`canEditData` — die fuenf einzelnen Abfragen in derselben Datei blieben
stehen, dazu je eine in KarenzPanel und TerminatePanel. Jetzt liest keine
mehr die Spalte; die beiden Panels bekommen den abgeleiteten Status
uebergeben, statt ihn sich selbst aus der Zeile zu holen.

Betroffen war ausser dem Wiedereintritt auch: welcher Austritts-Knopf
erscheint ("Nicht angetreten" statt "Austritt"), die Beschriftung der
Abwesenheit, die Vorbelegung der Beendigungsart und ob die Zugehoerigkeit
angezeigt wird.

Dazu: die Niederlassung ist aus dem Reiter Organisation wieder raus. Sie
stand dort als unsere Auslegung von Anforderung 8; der Kunde hat sie im
Gespraech gestrichen ("nimm's mal hier raus"). Was mit der Anforderung
gemeint war, bleibt offen.
2026-09-17 11:50:26 +02:00

199 lines
7.6 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, EmploymentStatus } from "@/lib/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;
}
// Der Status kommt als eigener Wert und nicht mehr aus der Zeile: die Spalte
// `employees.status` hängt nach, deshalb leitet die Akte ihn aus den Daten ab
// und reicht ihn durch (siehe lib/reports.ts, deriveStatusAsOf).
function zeige(teil: Partial<EmployeeRow> = {}, status?: EmploymentStatus) {
// Vorgabe aus der Zeile, damit die vorhandenen Fälle unverändert das meinen,
// was sie meinten — `zeige({ status: "Aktiv" })` beschreibt weiterhin eine
// aktive Person. Der abgeleitete Status ist nur noch ein eigener Weg zum
// selben Wert, und die Vorgabe „Geplant" entspricht der Zeile oben.
return render(
<ToastProvider>
<TerminatePanel
open
onClose={() => {}}
employee={mitarbeiter(teil)}
status={status ?? teil.status ?? "Geplant"}
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();
});
});