Record people who never turned up
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>
This commit is contained in:
@@ -12,7 +12,18 @@ import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
|
||||
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
||||
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
||||
const NO_SHOW = "No Show";
|
||||
const EXIT_REASONS = [
|
||||
"Einvernehmliche Auflösung",
|
||||
"Kündigung AN",
|
||||
"Kündigung AG",
|
||||
"Befristungsablauf",
|
||||
"Pensionierung",
|
||||
"Entlassung",
|
||||
NO_SHOW,
|
||||
];
|
||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
||||
|
||||
type TerminatePanelProps = {
|
||||
@@ -31,16 +42,32 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
|
||||
// an dem die Person hätte anfangen sollen. Die Datenbank setzt es ohnehin
|
||||
// so; hier steht es sichtbar, damit niemand ein Datum eintippt, das dann
|
||||
// stillschweigend übergangen wird.
|
||||
const istNoShow = reason === NO_SHOW;
|
||||
const wirksamesDatum = istNoShow ? employee.entry_date : exitDate;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!exitDate) {
|
||||
if (!wirksamesDatum) {
|
||||
showToast("Bitte ein Austrittsdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await terminateEmployee({ employee_id: employee.id, exit_date: exitDate, exit_reason: reason, note });
|
||||
const result = await terminateEmployee({
|
||||
employee_id: employee.id,
|
||||
exit_date: wirksamesDatum,
|
||||
exit_reason: reason,
|
||||
note,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`Austritt für ${employee.first_name} ${employee.last_name} erfasst.`);
|
||||
showToast(
|
||||
istNoShow
|
||||
? `${employee.first_name} ${employee.last_name} ist nicht angetreten.`
|
||||
: `Austritt für ${employee.first_name} ${employee.last_name} erfasst.`
|
||||
);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
@@ -71,15 +98,34 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||
</div>
|
||||
)}
|
||||
<TextField label="Austrittsdatum" required type="date" value={exitDate} onChange={setExitDate} />
|
||||
<SelectField
|
||||
label="Beendigungsart"
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
/>
|
||||
<TextField
|
||||
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
||||
required
|
||||
type="date"
|
||||
value={wirksamesDatum}
|
||||
disabled={istNoShow}
|
||||
onChange={setExitDate}
|
||||
hint={
|
||||
istNoShow
|
||||
? "Wer nie angetreten ist, scheidet am Tag seines Eintritts aus. Damit gibt es keinen Tag, an dem die Person als beschäftigt zählt."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
|
||||
<fieldset>
|
||||
{istNoShow && (
|
||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
Die Planstelle wird wieder frei und gilt als nie besetzt. In allen Auswertungen zählt die Person an keinem
|
||||
Stichtag als beschäftigt.
|
||||
</p>
|
||||
)}
|
||||
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
|
||||
<fieldset hidden={istNoShow}>
|
||||
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CHECKLIST_ITEMS.map((item, i) => (
|
||||
|
||||
113
supabase/migrations/20260814100000_no_show.sql
Normal file
113
supabase/migrations/20260814100000_no_show.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
-- Wer nie angetreten ist: Austrittsgrund „No Show".
|
||||
--
|
||||
-- Der Fall gibt es, und bisher liess er sich nicht erfassen. Ein Austritt am
|
||||
-- Eintrittstag scheiterte an chk_assignment_range: die Besetzung wurde auf
|
||||
-- valid_to = valid_from geschlossen, und ein leeres Intervall ist dort
|
||||
-- verboten. Ausweichen auf den Folgetag hätte bedeutet, einen Tag
|
||||
-- Beschäftigung zu behaupten, den es nie gab — mit allem, was daran hängt:
|
||||
-- Kopfzahl, Zugehörigkeit, Auswertungen zum Stichtag.
|
||||
--
|
||||
-- Drei Dinge macht dieser Grund deshalb anders:
|
||||
--
|
||||
-- * Das Austrittsdatum ist **immer** der Eintrittstag, unabhängig davon,
|
||||
-- was übergeben wurde. Daraus folgt „nie aktiv" von selbst: als
|
||||
-- beschäftigt gilt, wessen exit_date *nach* dem Stichtag liegt, und das
|
||||
-- ist hier an keinem Tag der Fall.
|
||||
-- * Die Planstellenzuordnung wird **entfernt**, nicht geschlossen. Die
|
||||
-- Stelle war nie besetzt und ist wieder frei.
|
||||
-- * Der Status springt sofort auf „Ausgetreten", auch bei einem Eintritt in
|
||||
-- der Zukunft. Sonst bliebe in der Spalte „Geplant" stehen — es gibt
|
||||
-- keinen Lauf, der das später nachzieht.
|
||||
--
|
||||
-- Die Bedingung unten hält das fest, egal auf welchem Weg jemand schreibt —
|
||||
-- auch über den Import.
|
||||
|
||||
alter table employees drop constraint if exists chk_no_show_am_eintritt;
|
||||
alter table employees add constraint chk_no_show_am_eintritt
|
||||
check (exit_reason is distinct from 'No Show' or exit_date = entry_date);
|
||||
|
||||
comment on constraint chk_no_show_am_eintritt on employees is
|
||||
'Ein Nichtantritt endet am Eintrittstag. Sonst gäbe es Tage, an denen die Person als beschäftigt zählte, obwohl sie nie da war. „is distinct from" statt „<>", damit ein leerer Grund nicht zu null auswertet und die Bedingung durchrutschen lässt.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.terminate_employee(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_exit date := (payload->>'exit_date')::date;
|
||||
v_name text;
|
||||
-- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie
|
||||
-- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten.
|
||||
v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show';
|
||||
v_entry date;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, entry_date into v_name, v_entry
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
-- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus.
|
||||
-- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war:
|
||||
-- die Statusableitung verlangt exit_date > Stichtag, um jemanden als
|
||||
-- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist
|
||||
-- damit keine zusätzliche Regel, sondern folgt aus dem Datum.
|
||||
if v_no_show then
|
||||
v_exit := v_entry;
|
||||
end if;
|
||||
|
||||
update employees set
|
||||
-- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der
|
||||
-- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen,
|
||||
-- denn es gibt keinen Lauf, der sie später nachzieht.
|
||||
status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end,
|
||||
exit_date = v_exit,
|
||||
exit_reason = payload->>'exit_reason'
|
||||
where id = v_employee_id;
|
||||
|
||||
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
|
||||
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
|
||||
-- die nächste besetzte Ebene.
|
||||
if v_no_show then
|
||||
-- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu
|
||||
-- schliessen ginge nicht — chk_assignment_range verlangt ein echtes
|
||||
-- Intervall, und genau daran scheiterte ein Austritt am Eintrittstag
|
||||
-- bisher. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder
|
||||
-- frei, und es steht nirgends, jemand hätte sie je innegehabt.
|
||||
delete from position_assignments
|
||||
where employee_id = v_employee_id and valid_to is null;
|
||||
else
|
||||
update position_assignments set valid_to = v_exit
|
||||
where employee_id = v_employee_id and valid_to is null;
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_exit, 'Austritt',
|
||||
case when v_no_show
|
||||
then 'Kein Antritt am ' || v_entry || ' (No Show)'
|
||||
else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id, case when v_no_show then 'Kein Antritt am ' || v_entry || ' (No Show)' else 'Austritt am ' || v_exit end);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_def text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure);
|
||||
begin
|
||||
if v_def not like '%v_no_show%' then
|
||||
raise exception 'terminate_employee kennt den Nichtantritt nicht';
|
||||
end if;
|
||||
if v_def not like '%delete from position_assignments%' then
|
||||
raise exception 'Die Zuordnung wird bei einem Nichtantritt nicht entfernt';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'chk_no_show_am_eintritt'
|
||||
) then
|
||||
raise exception 'Die Bedingung fehlt';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
133
tests/components/TerminatePanel.test.tsx
Normal file
133
tests/components/TerminatePanel.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user