Someone hired who then does not start needs an exit reason of its own, and until now the case could not be recorded at all. Terminating on the entry date failed on chk_assignment_range: the assignment was closed with valid_to = valid_from, and an empty interval is forbidden there. Moving the exit to the next day would have claimed a day of employment that never happened — headcount, tenure, every as-of report. "No Show" is now an exit reason, and it behaves differently in three ways. The exit date is always the entry date, whatever the caller passed. That is what makes "never active" true rather than asserted: a person counts as employed when their exit date is *after* the reporting date, and here it never is. The status derivation needed no change at all — it already says Geplant before the entry date and Ausgetreten from it on. The position assignment is deleted rather than closed. The post was never filled, it goes back to being open, and nothing records a holder who never held it. The status column goes to Ausgetreten immediately, even for an entry still in the future. Otherwise it would read Geplant forever — nothing runs later to correct it. A constraint holds the first of those regardless of the path in, including the import: exit_reason is distinct from 'No Show' or exit_date = entry_date. "is distinct from" rather than "<>" so an empty reason does not evaluate to null and slip through — the same three- valued trap that let an earlier check pass the case it was written to stop. The dialog locks the date field when No Show is picked and says why, so nobody types a date that would then be silently overridden. The offboarding checklist is hidden: nothing was ever handed out. Rehearsed against real data — a planned entry with a 2099 date passed in, which came back as the entry date; derived status across three reporting dates never Aktiv; a direct write with a mismatched date refused; and an ordinary termination unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
4.5 KiB
TypeScript
134 lines
4.5 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,
|
|
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();
|
|
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();
|
|
|
|
// 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();
|
|
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();
|
|
});
|
|
});
|