The offboarding list was a checkbox fieldset inside the exit panel — four items, never sent anywhere. Nothing in terminateEmployee's payload carried them; ticking a box there recorded exactly nothing. It's replaced with the same kind of list the entries got: its own tab, appearing the moment an exit is recorded, with one item per row, a comment on each, and — unlike the fieldset — a record of who touched it and when. The eleven items come from the same printed sheet as the entry list. Nine are plain checkboxes. Two are text fields under "Vermerke": remaining vacation and the balance transferred for payout — the sheet names "Überleitung Salden für Auszahlung" twice, once as a task to do and once as the actual figure, and those are genuinely two different questions, kept as two items. Where the sheet still says "GKK" rather than today's "ÖGK", it's left as written — that's the name the process runs under internally, not a typo. No Show gets no list. Never having worked a single day, there's no IT access to revoke, no GKK registration to undo, no Dienstzettel to collect — an empty checklist there would be a label with nothing behind it. Both the tab and the auto-creation on exit check for this specifically, not just the "Ausgetreten" status that No Show shares with a real exit. Rehiring the same person hides the tab again — the data stays, since it happened, but a checklist for someone currently working has nothing to point at. The engine (what counts as done, how progress is computed) moved into lib/checklist.ts so onboarding and offboarding can't drift into two different ideas of "done" the way two independent copies eventually do; lib/onboarding.ts and lib/offboarding.ts bind it to their own item list, and the tab UI is a single ChecklistPanel bound the same way. Checked against the real database: a real exit creates all eleven items in the same transaction as the exit itself; a No Show creates none; rehiring flips the tab off while the old answers stay queryable. One false alarm during that check turned out to be the user's own clicks on a real employee's onboarding list, made in the browser while trying the earlier feature — left untouched, not test debris. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
6.0 KiB
TypeScript
156 lines
6.0 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 { fmtDate, 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,
|
|
];
|
|
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("");
|
|
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
|
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
|
// übrigen zu verstellen.
|
|
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
|
const [note, setNote] = useState("");
|
|
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={istNoShow ? "Nicht angetreten" : "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">
|
|
{/* Zuerst, und nicht zu übersehen: bei besonderem Kündigungsschutz
|
|
gelten eigene Regeln, bevor beendet werden darf. Die Anwendung
|
|
entscheidet das nicht — sie darf es aber auch nicht verschweigen,
|
|
und im Vertragsblatt nachzusehen ist genau der Schritt, den man
|
|
unter Zeitdruck auslässt. */}
|
|
{employee.has_kuendigungsschutz && (
|
|
<div className="rounded border border-danger-solid bg-danger-bg px-3 py-2 text-sm font-semibold text-danger-text">
|
|
Achtung: besonderer Kündigungsschutz
|
|
{employee.kuendigungsschutz_bis ? ` bis ${fmtDate(employee.kuendigungsschutz_bis)}` : " (Ende nicht erfasst)"}.
|
|
<span className="block font-normal">
|
|
Vor einer Beendigung ist zu prüfen, ob sie zulässig ist — je nach Grund braucht es eine Zustimmung des
|
|
Betriebsrats oder des Gerichts.
|
|
</span>
|
|
</div>
|
|
)}
|
|
{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 —
|
|
deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen
|
|
echten Austritt legt terminateEmployee sie in derselben
|
|
Transaktion an; hier steht nur die Ankündigung, damit niemand sie
|
|
hier sucht und nichts findet. */}
|
|
{!istNoShow && (
|
|
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
|
Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</SlideOver>
|
|
);
|
|
}
|