Files
alpenwerk-hr/components/employees/panels/TerminatePanel.tsx
Maximilian Stubhan 0144267a59 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>
2026-08-14 12:33:14 +02:00

147 lines
5.2 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { terminateEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// „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 = {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
directReportCount: number;
};
export function TerminatePanel({ open, onClose, employee, directReportCount }: TerminatePanelProps) {
const { showToast } = useToast();
const router = useRouter();
const [exitDate, setExitDate] = useState("");
const [reason, setReason] = useState(EXIT_REASONS[0]);
const [note, setNote] = useState("");
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 (!wirksamesDatum) {
showToast("Bitte ein Austrittsdatum angeben.", "error");
return;
}
setPending(true);
const result = await terminateEmployee({
employee_id: employee.id,
exit_date: wirksamesDatum,
exit_reason: reason,
note,
});
setPending(false);
if (result.success) {
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 {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<SlideOver
open={open}
onClose={onClose}
title="Austritt"
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button variant="danger" onClick={handleSubmit} pending={pending}>
Austritt bestätigen
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{directReportCount > 0 && (
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
</div>
)}
<SelectField
label="Beendigungsart"
value={reason}
onChange={setReason}
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} />
{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) => (
<label key={item} className="flex items-center gap-2 text-sm text-ink-body">
<input
type="checkbox"
checked={checked[i]}
onChange={(e) => setChecked((prev) => prev.map((c, idx) => (idx === i ? e.target.checked : c)))}
/>
{item}
</label>
))}
</div>
</fieldset>
</div>
</SlideOver>
);
}