Last step moved the four part-time arrangements out of the absence list and recorded the reason in the history text. That answered "what happened" but not "who is in one right now", and the profile showed nothing at all. So it becomes a real field: teilzeit_art, with an optional end date. The objection I raised then still holds — a state goes stale, because nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is the answer to it: with an end date a report decides for itself what is still running instead of trusting that someone maintained the row. Left empty it means "open end", which is an honest thing to say. It runs through the ordinary change machinery rather than beside it. It sits in app_feld_karte, so it shows up in the history as a field with before and after, and can be corrected there like any other. The description suffix from last step is gone — writing the same thing twice is how two versions start disagreeing. Reporting: filter by variant, by "in one at all", and by when it ends; group headcount by variant, where the absence of one reads "Keine" rather than a dash, because in a report that is an answer and not a gap. Plus columns in the export and the import. One gap found while rehearsing, and only because the probe happened to pick a return date in the future: a scheduled return carries its payload through pending_org_changes, and that payload did not include the variant. Someone would have come back on reduced hours in April with the reason gone. The daily run now carries it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
6.6 KiB
TypeScript
180 lines
6.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 } 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",
|
|
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("blendet die Offboarding-Checkliste aus — ausgegeben wurde nie etwas", async () => {
|
|
const user = userEvent.setup();
|
|
zeige({ status: "Aktiv" });
|
|
expect(screen.getByText("Offboarding-Checkliste")).toBeVisible();
|
|
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
|
|
expect(screen.getByText("Offboarding-Checkliste")).not.toBeVisible();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|