Flag people who cannot simply be dismissed

Works council members, expectant mothers, parents on leave, registered
disabled employees, apprentices — each has its own rules that come
before a dismissal. The tool does not judge whether one is lawful, but
it must not stay quiet about it either, and looking it up on the
contract tab is exactly the step that gets skipped under time pressure.

So: a checkbox, an optional end date, and a red warning at the top of
the termination panel naming the date — or saying plainly that no end
was recorded. It shows for a no-show too; the protection runs from the
start of the contract, not the first day worked.

The date is optional on purpose. A works council mandate has a known
end, a pregnancy does not, and a mandatory field would force an invented
number. A constraint says only what cannot be: an end date without the
flag, which would be a leftover nobody could interpret.

The field goes the whole way through — hire, data change, contract
sheet, export, report criteria (as a yes/no and as a date range), and
the import. A field that exists in one screen and not the next is how
people stop trusting the numbers.

Terminating is now offered for planned entries as well, labelled "Nicht
angetreten", with No Show preselected. Without it a person who never
turned up stayed a planned entry forever, since nothing else can end
one.

One finding worth recording: tsc has been reporting success on a broken
program. A generated file under .next got corrupted when a build ran
against a live dev server, and its syntax errors suppressed semantic
checking everywhere else — two genuine type errors in this change went
unreported until I typechecked with .next excluded. The file is removed
and the ordinary typecheck is meaningful again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:44:00 +02:00
parent 0144267a59
commit 6681dcda77
18 changed files with 544 additions and 9 deletions

View File

@@ -106,14 +106,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
</>
)}
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
{isActive && (
{/* Auch bei einem geplanten Eintritt, und dort heisst es anders:
wer nie angetreten ist, „tritt" nicht „aus". Die Person hat
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
geplanter Eintritt stehen. */}
{(isActive || employee.status === "Geplant") && (
<Button
variant="secondary"
size="sm"
onClick={() => setPanel("terminate")}
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
>
<XCircle className="h-4 w-4" /> Austritt
<XCircle className="h-4 w-4" />
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
</Button>
)}
{employee.status === "Ausgetreten" && (

View File

@@ -1,6 +1,6 @@
"use client";
import { SelectField } from "@/components/ui/Field";
import { SelectField, TextField } from "@/components/ui/Field";
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -22,6 +22,14 @@ export type RoleEmploymentValue = {
dienstwagenArt: DienstwagenArt;
isLateraleFuehrung: boolean;
isCLevel: boolean;
/** Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehre. */
hasKuendigungsschutz: boolean;
/**
* Ende des Schutzes — freiwillig. Bei einem Betriebsratsmandat steht es
* fest, bei einer Schwangerschaft nicht; ein Pflichtfeld zwänge dort zu
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
*/
kuendigungsschutzBis: string;
};
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
@@ -110,6 +118,27 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
C-Level
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="checkbox"
checked={value.hasKuendigungsschutz}
onChange={(e) => onChange({ hasKuendigungsschutz: e.target.checked })}
/>
Besonderer Kündigungsschutz
</label>
{/* Wie bei der Antriebsart: erst sichtbar, wenn es einen Gegenstand
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
Weiteres", nicht „vergessen". */}
{value.hasKuendigungsschutz && (
<TextField
label="Geschützt bis"
dense
type="date"
value={value.kuendigungsschutzBis}
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
</div>
</div>
);

View File

@@ -73,6 +73,8 @@ export function DatenAendernPanel({
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: employee.is_betriebsrat ?? false,
hasDienstwagen: employee.has_dienstwagen ?? false,
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
@@ -145,6 +147,8 @@ export function DatenAendernPanel({
is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen,
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
has_kuendigungsschutz: role.hasKuendigungsschutz,
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel,
},

View File

@@ -8,7 +8,7 @@ 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";
import { fmtDate, fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -37,7 +37,10 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
const { showToast } = useToast();
const router = useRouter();
const [exitDate, setExitDate] = useState("");
const [reason, setReason] = useState(EXIT_REASONS[0]);
// 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 [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
const [pending, setPending] = useState(false);
@@ -79,7 +82,7 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
<SlideOver
open={open}
onClose={onClose}
title="Austritt"
title={istNoShow ? "Nicht angetreten" : "Austritt"}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
@@ -93,6 +96,21 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
}
>
<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.

View File

@@ -35,6 +35,16 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"],
["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"],
["C-Level", employee.is_c_level ? "Ja" : "Nein"],
// Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der
// Schutz noch läuft, und genau danach fragt man.
[
"Besonderer Kündigungsschutz",
employee.has_kuendigungsschutz
? employee.kuendigungsschutz_bis
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
: "Ja (Ende offen)"
: "Nein",
],
];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -130,6 +130,9 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
// sonst nicht zu.
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
has_kuendigungsschutz: draft.hasKuendigungsschutz,
// Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu.
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,

View File

@@ -25,6 +25,8 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
draft.isLateraleFuehrung && "Laterale Führung",
draft.isCLevel && "C-Level",
draft.hasKuendigungsschutz &&
`Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`,
].filter(Boolean);
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
.filter(Boolean)

View File

@@ -56,6 +56,9 @@ export type HireDraftData = {
angehoerige: HireDraftAngehoerige[];
isLateraleFuehrung: boolean;
isCLevel: boolean;
hasKuendigungsschutz: boolean;
/** Freiwillig — leer heisst „bis auf Weiteres". */
kuendigungsschutzBis: string;
};
export const EMPTY_HIRE_DRAFT: HireDraftData = {
@@ -90,4 +93,6 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
angehoerige: [],
isLateraleFuehrung: false,
isCLevel: false,
hasKuendigungsschutz: false,
kuendigungsschutzBis: "",
};