Migration 20260917100000. Ruecknahme einer eigenen Entscheidung, nach der
Erklaerung des Kunden am 17.09.2026.
Wir hatten den Anstoss aus der Beendigungsart abgeleitet — Kuendigung AN
gilt als freiwillig, Kuendigung AG als unfreiwillig. Das schien sauberer,
weil es zwei Felder ausschliesst, die einander widersprechen koennen.
In Oesterreich stimmt es nicht. Die einvernehmliche Aufloesung ist hier der
Regelfall und sagt ueber den Anstoss nichts aus: sie kann von der Person
ausgehen ("ich moechte kuendigen", worauf einvernehmlich aufgeloest wird,
damit das AMS zahlt) oder vom Dienstgeber ("ich will die Trennung, dafuer
gibt es eine Abfindung"). Dieselbe Beendigungsart, zwei gegensaetzliche
Antworten — und das ist genau die Unterscheidung, auf die es bei einer
Fluktuationsanalyse ankommt. Die Ableitung haette die Haelfte der Faelle
still falsch einsortiert.
Im Formular steht jetzt die Beendigungsart oben mit allen Werten, darunter
"Freiwillig oder unfreiwillig". Keine der beiden schraenkt die andere ein.
Der abgeleitete Hinweis unter der Beendigungsart ist weg — er erschien von
selbst und sah aus wie ein Fehler des Formulars.
Die Angabe ist freiwillig: der Bestand traegt sie nicht, und ein
Befristungsablauf geschieht auf niemandes Betreiben. Ob sie fuer gewoehnliche
Austritte Pflicht werden soll, ist eine Frage an den Kunden.
rehire_employee raeumt sie mit dem Austritt weg. Die Funktion ist dabei
ausgeschrieben worden; die Selbstpruefung haelt fest, dass die beiden
Umstellungen, die sie schon hinter sich hatte (app_current_user_id statt
auth.uid, fester search_path), dabei nicht verlorengehen — genau das ist der
Fehler, den ein create-or-replace aus einer alten Vorlage leise macht.
184 lines
8.0 KiB
TypeScript
184 lines
8.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 { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNG_NO_SHOW, BEENDIGUNGSART_WERTE } from "@/lib/beendigung";
|
|
import type { Austrittsart, 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]);
|
|
// Leer als Vorbelegung: die Angabe ist freiwillig, und eine vorausgewählte
|
|
// Seite wäre eine Behauptung, die niemand getroffen hat.
|
|
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
|
|
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,
|
|
// Leer statt undefined: die SQL-Funktion macht daraus null, und null
|
|
// heisst „nicht erfasst" — das ist der ehrliche Wert.
|
|
austrittsart,
|
|
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>
|
|
)}
|
|
{/* ── Zwei Angaben, die einander nicht bestimmen ──────────────
|
|
Erst die Beendigungsart, darunter der Anstoss. Beide Listen sind
|
|
vollständig und keine schränkt die andere ein.
|
|
|
|
Der erste Entwurf hatte es umgekehrt: oben die Austrittsart, und
|
|
sie filterte die Beendigungsarten darunter, weil der Anstoss aus
|
|
der Art ableitbar schien. In Österreich ist er das nicht — die
|
|
einvernehmliche Auflösung ist der Regelfall und kann von beiden
|
|
Seiten ausgehen. Die Begründung steht in lib/beendigung.ts.
|
|
|
|
Unter der Beendigungsart stand ausserdem der abgeleitete Anstoss
|
|
als Hinweis. Er erschien von selbst und sah aus wie ein Fehler des
|
|
Formulars; er ist weg. */}
|
|
<SelectField
|
|
label="Beendigungsart"
|
|
value={reason}
|
|
onChange={setReason}
|
|
options={BEENDIGUNGSART_WERTE.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
|
/>
|
|
<SelectField
|
|
label="Freiwillig oder unfreiwillig"
|
|
value={austrittsart}
|
|
onChange={(v) => setAustrittsart((v || "") as Austrittsart | "")}
|
|
options={[
|
|
// „Nicht erfasst" als Vorbelegung und nicht eine der beiden
|
|
// Seiten: ein Befristungsablauf geschieht auf niemandes
|
|
// Betreiben, und ein Nichtantritt ist gar kein Austritt. Eine
|
|
// erzwungene Antwort wäre dort eine erfundene Zahl.
|
|
{ value: "", label: "Nicht erfasst" },
|
|
...AUSTRITTSARTEN.map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
|
|
]}
|
|
hint="Für die Fluktuationsauswertung. Lässt sich aus der Beendigungsart nicht ableiten."
|
|
/>
|
|
<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>
|
|
);
|
|
}
|