Files
alpenwerk-hr/components/employees/panels/TerminatePanel.tsx
Andrei Laas 4165f3f8a1 Der Wiedereintritt war da, nur nicht erreichbar
Aus dem Gespraech vom 17.09.2026.

Der Kunde suchte den Knopf "Wiedereintritt" an einer ausgetretenen Person
und hielt ihn fuer verschwunden — "ich dachte eigentlich, das ist schon
implementiert, das war mal drin". Er war drin. Er hing nur an
employee.status, und diese Spalte haengt nach: an einer Person, deren
Austritt erfasst und inzwischen vollzogen war, stand dort weiter "Aktiv".

Das ist mein Fehler beim Statusfix. Umgestellt waren dort `isActive` und
`canEditData` — die fuenf einzelnen Abfragen in derselben Datei blieben
stehen, dazu je eine in KarenzPanel und TerminatePanel. Jetzt liest keine
mehr die Spalte; die beiden Panels bekommen den abgeleiteten Status
uebergeben, statt ihn sich selbst aus der Zeile zu holen.

Betroffen war ausser dem Wiedereintritt auch: welcher Austritts-Knopf
erscheint ("Nicht angetreten" statt "Austritt"), die Beschriftung der
Abwesenheit, die Vorbelegung der Beendigungsart und ob die Zugehoerigkeit
angezeigt wird.

Dazu: die Niederlassung ist aus dem Reiter Organisation wieder raus. Sie
stand dort als unsere Auslegung von Anforderung 8; der Kunde hat sie im
Gespraech gestrichen ("nimm's mal hier raus"). Was mit der Anforderung
gemeint war, bleibt offen.
2026-09-17 11:50:26 +02:00

188 lines
7.9 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 {
AUSTRITTSART_LABELS,
BEENDIGUNG_NO_SHOW,
BEENDIGUNGSART_WERTE,
austrittsartVon,
beendigungsartenZu,
type Austrittsart,
} from "@/lib/beendigung";
import type { Database, EmploymentStatus } from "@/lib/types";
import { fmtDate, fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager
// braucht sie ebenso, und zwei Listen liefen auseinander.
const NO_SHOW = BEENDIGUNG_NO_SHOW;
type TerminatePanelProps = {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
status: EmploymentStatus;
directReportCount: number;
};
export function TerminatePanel({ open, onClose, employee, status, 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(status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
// „Alle" statt einer Vorauswahl: die Einschränkung ist eine Hilfe beim
// Suchen, keine Pflichtangabe. Wer die gewünschte Art auswendig kennt,
// soll sie unmittelbar wählen können.
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
const waehlbar = beendigungsartenZu(austrittsart || null);
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>
)}
{/* Die Austrittsart wählt nicht mit, sie engt ein. Gespeichert wird
allein die Beendigungsart; auf wessen Betreiben beendet wurde,
leitet lib/beendigung.ts daraus ab. Als zweites freies Feld liesse
sich „Entlassung, freiwillig" erfassen, und zwei Auswertungen über
dieselbe Person kämen zu verschiedenen Ergebnissen. */}
<SelectField
label="Austrittsart"
value={austrittsart}
onChange={(v) => {
const neu = (v || "") as Austrittsart | "";
setAustrittsart(neu);
// Passt der bisher gewählte Grund nicht mehr zur Einschränkung,
// wandert die Auswahl auf den ersten passenden. Stehenzulassen,
// was in der Liste nicht mehr sichtbar ist, wäre ein Formular,
// das etwas anderes absendet, als es zeigt.
const erlaubt = beendigungsartenZu(neu || null);
if (!erlaubt.includes(reason)) setReason(erlaubt[0]);
}}
options={[
{ value: "", label: "Alle Beendigungsarten" },
...(Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
]}
hint="Schränkt die Auswahl darunter ein. Gespeichert wird die Beendigungsart."
/>
<SelectField
label="Beendigungsart"
value={reason}
onChange={setReason}
options={waehlbar.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
hint={austrittsartVon(reason) ? AUSTRITTSART_LABELS[austrittsartVon(reason)!] : undefined}
/>
<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>
);
}