Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert — als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das Dropdown im Formular schraenkt die Auswahl darunter ein. Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite zuzuschlagen wuerde jede Fluktuationsquote verfaelschen. Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen. Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg; "Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines zusteht, nicht ob es uebergeben wurde. Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren vorher nur ueber den Export erreichbar. Stammdatenaenderung und Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt, Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export. Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet, hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste man selbst.
186 lines
7.8 KiB
TypeScript
186 lines
7.8 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 } 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;
|
|
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 : 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>
|
|
);
|
|
}
|